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
5 changed files with 101 additions and 23 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(())
}
+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,