Files
figcraft/crates/format/src/library.rs
T
bflatastic 7edc61b8d2 M9: harden open hosting against resource exhaustion
Fixes from the security review of the public server:
- team libraries only in key-published files on open servers; unpacking
  bounded by the file cap (zip bombs refused)
- byte-bounded send queues per connection and in all, a bound on bytes
  waiting for the hub, per-address and global download budgets
- batches checked against the file, per-address and total caps as files
  grow (refused with a Reject the app rolls back); the core's recent-batch
  memory bounded in bytes; nothing confirmed when a log can't be written
- usage reports kept in memory; per-kind message caps checked while read
- layered IPv6 limits (/64, /56, /48), a global publish rate, connections
  reserved for sessions, short idle timeout for one-shot requests
- revoked links and removed files cut library connections
- data directory 0700, server key 0600 from creation
- install.sh: SSH ports from sshd, ufw left off when other services
  listen, unprivileged builds of the latest tag, settings checked before
  use, own Caddy snippet; systemd memory and network caps; backups check
  free space and say they hold the key
- linger reads raw bytes after an oversized frame (tungstenite asserted)
2026-10-12 11:40:03 +07:00

119 lines
5.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A published library inside a `.figcraft` archive.
//!
//! `library/manifest.json` holds the [`LibraryManifest`]; `library/document.json` the snapshot of
//! the published assets (a `document.json` of its own). Images the snapshot uses are read from
//! `images/` (the file's own images) or, when the file itself no longer uses them,
//! `library/images/<sha256>`. Readers that don't know about libraries ignore these entries; files
//! without them load unchanged.
//!
//! Files using a library read only these entries ([`read_library`]), with size limits on each,
//! so a missing, damaged or oversized library file is an error, never a crash.
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Read, Seek, Write};
use std::sync::Arc;
use figcraft_doc::{ImageData, LibraryManifest, NodeKind, PublishedLibrary, used_images};
use crate::{FormatError, MAX_MEMBER, from_json, image_data, io_err, json_options, put, put_json, read_member};
const MANIFEST: &str = "library/manifest.json";
const SNAPSHOT: &str = "library/document.json";
const IMAGES: &str = "library/images/";
/// Largest manifest read.
const MAX_MANIFEST: u64 = 16 * 1024 * 1024;
/// Most bytes of images read for one library.
const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024;
/// Longest library id and name kept.
const MAX_ID: usize = 64;
const MAX_NAME: usize = 200;
const MAX_DESCRIPTION: usize = 2000;
fn corrupt(e: impl std::fmt::Display) -> FormatError {
FormatError::Corrupt(e.to_string())
}
/// Whether `id` can be a library id: 1–64 ASCII letters, digits, `-` and `_`.
pub fn is_valid_library_id(id: &str) -> bool {
(1..=MAX_ID).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// Image hashes become archive member names: only plain hex-like names are looked up.
fn is_valid_hash(h: &str) -> bool {
(1..=128).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric())
}
/// Write the published library's entries. `written` are the images already in `images/`.
pub(crate) fn write<W: Write + Seek>(z: &mut zip::ZipWriter<W>, lib: &PublishedLibrary, written: &BTreeSet<&String>) -> Result<(), FormatError> {
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
put(z, MANIFEST, json_options(), &serde_json::to_vec(&lib.manifest).map_err(io_err)?)?;
put_json(z, SNAPSHOT, &lib.doc)?;
for (hash, img) in &lib.doc.images {
if !written.contains(hash) && is_valid_hash(hash) {
put(z, &format!("{IMAGES}{hash}"), stored, &img.bytes)?;
}
}
Ok(())
}
/// The published library in an archive (`None` when the file was never published). `own` are
/// the images already read from `images/`; `budget` caps the bytes its snapshot and images unpack
/// to, together (beyond the per-part caps).
pub(crate) fn read<R: Read + Seek>(
z: &mut zip::ZipArchive<R>,
own: &BTreeMap<String, ImageData>,
budget: u64,
) -> Result<Option<PublishedLibrary>, FormatError> {
if z.index_for_name(MANIFEST).is_none() {
return Ok(None);
}
let mut manifest: LibraryManifest = serde_json::from_slice(&read_member(z, MANIFEST, MAX_MANIFEST)?).map_err(corrupt)?;
if !is_valid_library_id(&manifest.id) {
return Err(corrupt("the library has an invalid id"));
}
let text = String::from_utf8(read_member(z, SNAPSHOT, budget.min(MAX_MEMBER))?).map_err(corrupt)?;
let mut budget = budget.saturating_sub(text.len() as u64).min(MAX_IMAGE_BYTES);
let (mut doc, _) = from_json(&text)?;
for hash in used_images(&doc) {
if !is_valid_hash(&hash) {
continue;
}
if let Some(img) = own.get(&hash) {
doc.images.insert(hash, img.clone());
continue;
}
let name = [format!("{IMAGES}{hash}"), format!("images/{hash}")].into_iter().find(|n| z.index_for_name(n).is_some());
// A missing or oversized image shows as a placeholder, like in any other file.
let Some(data) = name.and_then(|n| read_member(z, &n, budget.min(MAX_MEMBER)).ok()) else { continue };
budget = budget.saturating_sub(data.len() as u64);
doc.images.insert(hash, image_data(data));
}
// Keep what the snapshot really holds; clip what people see.
manifest.components.retain(|a| doc.node(a.id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })));
manifest.styles.retain(|s| doc.styles.iter().any(|x| x.id == s.id));
manifest.collections.retain(|c| doc.variables.collections.iter().any(|x| x.id == c.id));
manifest.variables.retain(|v| doc.variables.variables.iter().any(|x| x.id == v.id && x.collection == v.collection));
manifest.name = manifest.name.chars().take(MAX_NAME).collect();
manifest.description = manifest.description.chars().take(MAX_DESCRIPTION).collect();
Ok(Some(PublishedLibrary { manifest, doc: Arc::new(doc) }))
}
/// Read the published library of a `.figcraft` file (only its library entries are read): `None`
/// when the file was never published, an error when it isn't a FigCraft archive or is damaged.
pub fn read_library<R: Read + Seek>(r: R) -> Result<Option<PublishedLibrary>, FormatError> {
let mut z = zip::ZipArchive::new(r).map_err(corrupt)?;
read(&mut z, &BTreeMap::new(), u64::MAX)
}
/// [`read_library`] unpacking at most `max_bytes` in all (snapshot and images: an archive that
/// would unpack to more is refused, or loses the images past it), for libraries from people the
/// reader doesn't trust (a server).
pub fn read_library_within<R: Read + Seek>(r: R, max_bytes: u64) -> Result<Option<PublishedLibrary>, FormatError> {
let mut z = zip::ZipArchive::new(r).map_err(corrupt)?;
read(&mut z, &BTreeMap::new(), max_bytes)
}
#[cfg(test)]
#[path = "library_tests.rs"]
mod tests;