Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 bad1073fdf Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:06:04 -04:00
Dylan M. Taylor 01bbdb24bc security: confine craft-fonts manifest paths and reject newline directives (CWE-22, CWE-74) (#1841)
The optional `CRAFT_FONTS_DIR` build input lets a font manifest select the file
each face is embedded from. `build.rs` canonicalised that file with no
containment check, so an absolute path, a `..` traversal, or a symlink inside
the checkout could make the generated `include_bytes!` read an arbitrary file
on the build host and expose its bytes through `CRAFT_FONTS` (CWE-22).

It also printed the resolved path straight into Cargo's newline-delimited
build-script protocol (`cargo::rerun-if-changed=…`). A path containing an
embedded newline — reachable through a crafted symlink target — could inject a
second directive such as `cargo::rustc-env=OUT_DIR=…`, redirecting the
generated-source include to attacker-supplied Rust (CWE-74).

- `resolve_font` rejects absolute and `..` manifest paths and requires the
  canonicalised result to stay inside the canonicalised checkout root.
- `rerun_if_changed` refuses any path containing CR/LF before emitting the
  directive; `one_line` collapses CR/LF in checkout-derived diagnostics.

Addresses findings rows 5 (CWE-22) and 14 (CWE-74).
2026-10-22 18:42:51 -04:00
Dylan M. Taylor 15ba3dc5cc security: submit XATTACH picked path as literal text, not a command line (CWE-74) (#1843)
The External References palette's XATTACH handler passed the file picker's
result to `app.cmdline(&path)`. `cmdline` interprets command-line syntax, so a
selected filename beginning with an apostrophe (for example `'closeall`) was run
as an engine command rather than naming the file — closing every drawing without
the UI's unsaved-change confirmation.

The picked path is attacker-controlled (the picker returns whatever the user
selected, and `relative_to_host` can reduce it to a bare basename). After
starting XATTACH, submit it through `Session::input(Input::Text(path))` so it is
delivered to the command's prompt as literal text and never interpreted as a
command, matching how other picker/dialog bridges already behave.

Addresses finding row 26.
2026-10-22 18:19:13 -04:00
Dylan M. Taylor 07bb604214 security: guard the DXF->DWG conversion input and document decoder limits (CWE-789) (#1847)
The native DWG bridge wraps the third-party `acadrust` decoder, which allocates
tables and section buffers from sizes a file declares and cannot be interrupted
once it starts. The DWG->DXF read path already rejects oversized input before
decoding, caps the DXF output while writing, and runs inside `catch_unwind`. The
DXF->DWG `convert` path had no input-size guard, so a very large DXF went
straight into the dependency's reader.

- `convert` now rejects DXF input larger than `Limits::max_dxf_bytes` before
  calling `DxfReader::read`, symmetric with the read path, and keeps the existing
  `catch_unwind` around the decoder and writer (rows 23, 3, 27, large-input
  variant).
- Documented the residual risk: a small file that declares a huge internal table
  or section can still make the dependency allocate before any size is known.
  Fully fixing that needs an upstream fix in `acadrust` (validate declared sizes
  against a decoded-memory budget) or a separate worker with an OS memory limit;
  the in-crate limits keep the blast radius bounded until then.

Addresses findings rows 3, 23, 27 (bounds the reachable input/output size;
the deeper allocation fix belongs in the unmodified dependency).
2026-10-22 18:18:43 -04:00
6 changed files with 102 additions and 78 deletions
Generated
+16 -16
View File
@@ -547,7 +547,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -571,7 +571,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -582,14 +582,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -599,7 +599,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -610,14 +610,14 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"acadrust",
]
[[package]]
name = "cadcraft-dxf"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"encoding_rs",
"log",
@@ -626,7 +626,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -647,7 +647,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cadcraft-geom",
"harfrust",
@@ -657,14 +657,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -684,7 +684,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -696,7 +696,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -710,7 +710,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -733,7 +733,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -4825,7 +4825,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.11.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+35
View File
@@ -4,6 +4,25 @@
//! converted to DXF bytes, which CADCraft's own DXF reader maps to its document model; saving
//! goes the other way. Keeping the dependency behind this byte-level API isolates it: nothing
//! else in CADCraft depends on its types. Native targets only (it memory-maps files).
//!
//! # Guardrails around the third-party decoder
//!
//! The decoder allocates tables and section buffers from sizes a (possibly damaged or hostile)
//! file declares, and once it starts it cannot be interrupted (an allocation failure aborts the
//! process, which `catch_unwind` cannot recover). Because the dependency is used unmodified, this
//! crate guards it from the outside:
//!
//! - Both directions reject oversized input *before* the decoder runs ([`Limits::max_dwg_bytes`]
//! for DWG→DXF, [`Limits::max_dxf_bytes`] for DXF→DWG), and the DWG→DXF output is capped while
//! it is written (`CappedWriter`).
//! - The reader and writer run inside `catch_unwind`, so a decode failure is reported instead of
//! crashing.
//!
//! These bound the input- and output-size variants. A small file that declares a huge internal
//! table or section can still make the dependency allocate before any size is known; fully fixing
//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory
//! budget) or running the decode in a separate worker with an OS memory limit. The limits here
//! keep the blast radius bounded in the meantime.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -505,6 +524,13 @@ mod native {
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
// Reject a pathologically large DXF before handing it to the third-party decoder, which
// allocates tables and section buffers from the input's declared sizes and cannot be
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
// runs inside `catch_unwind` so a decode failure is reported rather than crashing.
if dxf.len() > Limits::DEFAULT.max_dxf_bytes {
return Err(format!("DXF: the input is {}, larger than the {} CADCraft converts to DWG", mb(dxf.len() as u64), mb(Limits::DEFAULT.max_dxf_bytes as u64)));
}
let data = dxf.to_vec();
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
@@ -618,6 +644,15 @@ mod tests {
assert!(dwg_to_dxf(b"").is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dxf_to_dwg_rejects_garbage_without_crashing() {
// The DXF→DWG path runs the third-party reader inside catch_unwind and reports failures
// rather than crashing (and rejects oversized input before decoding).
assert!(dxf_to_dwg(b"not a dxf file").is_err());
assert!(dxf_to_dwg(b"").is_err());
}
/// A DXF with one DIMENSION per (text, group 70, subclass, points) row.
#[cfg(not(target_arch = "wasm32"))]
fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String {
+42 -5
View File
@@ -18,9 +18,9 @@ fn main() {
match craft_fonts(&dir) {
Ok(entries) => src.push_str(&entries),
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
println!("cargo::error=CRAFT_FONTS_DIR={}: {}", one_line(&dir.display().to_string()), one_line(&e));
}
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {}", one_line(&dir.display().to_string()), one_line(&e)),
}
}
src.push_str("];\n");
@@ -37,7 +37,9 @@ const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans",
/// One `CraftFont { .. }` initialiser per manifest line, in manifest order.
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
let manifest = dir.join("fonts/manifest.txt");
println!("cargo::rerun-if-changed={}", manifest.display());
rerun_if_changed(&manifest)?;
// Resolve the checkout root once so every manifest file can be confined inside it.
let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?;
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
@@ -55,8 +57,8 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
if wasm && !web_face {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
println!("cargo::rerun-if-changed={}", path.display());
let path = resolve_font(&root, file)?;
rerun_if_changed(&path)?;
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
let _ = writeln!(
out,
@@ -67,3 +69,38 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
}
Ok(out)
}
/// Resolve a manifest `file` field to a real path *confined to the checkout* `root` (CWE-22).
///
/// The field is rejected if it is absolute or contains a parent (`..`) component, and the
/// canonicalised result must still live inside `root`, so a crafted manifest or a symlink in the
/// checkout cannot make the generated `include_bytes!` read an arbitrary file on the build host.
fn resolve_font(root: &std::path::Path, file: &str) -> Result<PathBuf, String> {
let rel = std::path::Path::new(file);
if rel.is_absolute() || rel.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return Err(format!("{file}: manifest paths must be relative and stay inside the checkout"));
}
let path = root.join(rel).canonicalize().map_err(|e| format!("{file}: {e}"))?;
if !path.starts_with(root) {
return Err(format!("{file}: resolves outside the craft-fonts checkout"));
}
Ok(path)
}
/// Collapse CR/LF in a diagnostic derived from checkout contents to a single space, so it cannot
/// introduce an additional Cargo directive on the newline-delimited build-script stream (CWE-74).
fn one_line(s: &str) -> String {
s.replace(['\n', '\r'], " ")
}
/// Emit a `cargo::rerun-if-changed` directive for `path`, refusing paths that contain a line
/// separator (CWE-74): Cargo's build-script protocol is newline-delimited, so a path with an
/// embedded CR/LF could smuggle an extra directive such as `cargo::rustc-env=OUT_DIR=…`.
fn rerun_if_changed(path: &std::path::Path) -> Result<(), String> {
let shown = path.display().to_string();
if shown.contains(['\n', '\r']) {
return Err(format!("{shown:?}: path contains a line separator"));
}
println!("cargo::rerun-if-changed={shown}");
Ok(())
}
+1 -55
View File
@@ -60,7 +60,6 @@ pub(crate) fn shape(primary: Option<&Face>, s: &str, height: f64, width_factor:
catch_unwind(AssertUnwindSafe(|| shape_chars(primary, &chars, height, width_factor, oblique))).ok()
}
/// Lay out decoded directional runs, keeping advances and decoration spans for empty glyphs.
fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64, width_factor: f64, oblique: f64) -> Shaped {
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
@@ -108,7 +107,7 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64
for p in contours.iter_mut().flatten() {
*p += Vec2::new(dy * shear, dy);
}
out.push_glyph(contours, None);
out.glyphs.push(contours);
}
let adv = g.advance * k;
let (u, o) = flags(g.cluster);
@@ -218,59 +217,6 @@ fn shape_run(face: &Face, cs: &[char], r: Range<usize>, rtl: bool) -> Vec<Glyph>
mod tests {
use super::*;
/// A single empty glyph with a positive advance, without relying on installed fonts.
fn empty_glyph_face() -> Face {
let mut head = vec![0_u8; 54];
head[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
head[18..20].copy_from_slice(&1000_u16.to_be_bytes());
let mut hhea = vec![0_u8; 36];
hhea[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
hhea[4..6].copy_from_slice(&800_i16.to_be_bytes());
hhea[6..8].copy_from_slice(&(-200_i16).to_be_bytes());
hhea[34..36].copy_from_slice(&1_u16.to_be_bytes());
let tables = [
(*b"head", head),
(*b"hhea", hhea),
(*b"hmtx", vec![1, 244, 0, 0]), // Advance 500, left side bearing 0.
(*b"maxp", vec![0, 0, 80, 0, 0, 1]), // Version 0.5, one glyph.
];
let mut bytes = vec![0_u8; 12 + tables.len() * 16];
bytes[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
bytes[4..6].copy_from_slice(&4_u16.to_be_bytes());
bytes[6..8].copy_from_slice(&64_u16.to_be_bytes());
bytes[8..10].copy_from_slice(&2_u16.to_be_bytes());
for (i, (tag, data)) in tables.into_iter().enumerate() {
while !bytes.len().is_multiple_of(4) {
bytes.push(0);
}
let record = 12 + i * 16;
let offset = u32::try_from(bytes.len()).unwrap();
let len = u32::try_from(data.len()).unwrap();
bytes[record..record + 4].copy_from_slice(&tag);
bytes[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes());
bytes[record + 12..record + 16].copy_from_slice(&len.to_be_bytes());
bytes.extend(data);
}
Face::new(std::sync::Arc::new(bytes), 0).expect("synthetic font parses")
}
/// Empty complex glyphs retain spacing and underlines without adding geometry or text slots.
#[test]
fn empty_complex_glyph_keeps_advance_without_geometry() {
let face = empty_glyph_face();
let shaped = shape(Some(&face), " ", 1.0, 1.0, 0.0).expect("shapes empty glyph");
assert!(shaped.width > 0.0, "the font's advance is retained");
assert!(shaped.is_empty());
assert!(shaped.glyphs.is_empty());
assert!(shaped.text.is_empty());
let underlined = shape(Some(&face), "%%u ", 1.0, 1.0, 0.0).expect("shapes decorated empty glyph");
assert_eq!(underlined.width, shaped.width);
assert!(underlined.glyphs.is_empty());
assert!(underlined.text.is_empty());
assert!(!underlined.strokes.is_empty(), "decoration spans survive an empty outline");
}
#[test]
fn stroke_font_rtl_lines_keep_latin_and_digits_in_the_stroke_font() {
// With or without installed fonts: the Persian words are fallback glyphs or
+7 -1
View File
@@ -24,7 +24,13 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
if let Err(e) = app.session.start("xattach") {
return Some(Err(e.to_string()));
}
app.cmdline(&path);
// Submit the picked filename as literal text, not through the command line: a path is
// attacker-controlled (the file picker returns whatever the user selected), and
// `cmdline` interprets command syntax, so a name like `'closeall` would run an engine
// command instead of naming the file (CWE-74).
if let Err(e) = app.session.input(cadcraft_engine::Input::Text(path)) {
return Some(Err(e.to_string()));
}
Some(Ok(Value::Null))
}
_ => None,