#[cfg(any(
feature = "archives",
feature = "hwpx",
feature = "iwork",
feature = "office",
feature = "excel",
feature = "excel-wasm"
))]
use std::io::{Read, Seek};
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(default, deny_unknown_fields)]
pub struct SecurityLimits {
pub max_archive_size: usize,
pub max_compression_ratio: usize,
pub max_files_in_archive: usize,
pub max_nesting_depth: usize,
pub max_entity_length: usize,
pub max_content_size: usize,
pub max_iterations: usize,
pub max_xml_depth: usize,
pub max_table_cells: usize,
pub max_pages: Option<usize>,
}
impl Default for SecurityLimits {
fn default() -> Self {
Self {
max_archive_size: 500 * 1024 * 1024,
max_compression_ratio: 100,
max_files_in_archive: 10_000,
max_nesting_depth: 1024,
max_entity_length: 1024 * 1024,
max_content_size: 100 * 1024 * 1024,
max_iterations: 10_000_000,
max_xml_depth: 1024,
max_table_cells: 100_000,
max_pages: None,
}
}
}
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone)]
pub enum SecurityError {
ZipBombDetected {
compressed_size: u64,
uncompressed_size: u64,
ratio: f64,
},
ArchiveTooLarge {
size: u64,
max: usize,
},
TooManyFiles {
count: usize,
max: usize,
},
NestingTooDeep {
depth: usize,
max: usize,
},
ContentTooLarge {
size: usize,
max: usize,
},
EntityTooLong {
length: usize,
max: usize,
},
TooManyIterations {
count: usize,
max: usize,
},
XmlDepthExceeded {
depth: usize,
max: usize,
},
TooManyCells {
cells: usize,
max: usize,
},
TooManyPages {
count: usize,
max: usize,
},
UnreadableEntry {
index: usize,
reason: String,
},
}
impl std::fmt::Display for SecurityError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
SecurityError::ZipBombDetected {
compressed_size,
uncompressed_size,
ratio,
} => {
write!(
f,
"Potential ZIP bomb detected: compressed {}B -> uncompressed {}B (ratio: {:.1}:1)",
compressed_size, uncompressed_size, ratio
)
}
SecurityError::ArchiveTooLarge { size, max } => {
write!(f, "Archive too large: {} bytes (max: {} bytes)", size, max)
}
SecurityError::TooManyFiles { count, max } => {
write!(f, "Archive has too many files: {} (max: {})", count, max)
}
SecurityError::NestingTooDeep { depth, max } => {
write!(f, "Nesting too deep: {} levels (max: {})", depth, max)
}
SecurityError::ContentTooLarge { size, max } => {
write!(f, "Content too large: {} bytes (max: {} bytes)", size, max)
}
SecurityError::EntityTooLong { length, max } => {
write!(f, "Entity too long: {} chars (max: {})", length, max)
}
SecurityError::TooManyIterations { count, max } => {
write!(f, "Too many iterations: {} (max: {})", count, max)
}
SecurityError::XmlDepthExceeded { depth, max } => {
write!(f, "XML depth exceeded: {} (max: {})", depth, max)
}
SecurityError::TooManyCells { cells, max } => {
write!(
f,
"Table cell limit exceeded: observed {} cells, but \
`security_limits.max_table_cells` is {}. If this input is trusted, raise \
`security_limits.max_table_cells`; otherwise reduce or split the table.",
cells, max
)
}
SecurityError::TooManyPages { count, max } => {
write!(
f,
"Document has too many pages: {} (max: {}). Raise `security_limits.max_pages` \
if this document is legitimate, or split it before extraction.",
count, max
)
}
SecurityError::UnreadableEntry { index, reason } => {
write!(
f,
"Archive entry {} could not be read for security accounting: {}",
index, reason
)
}
}
}
}
impl std::error::Error for SecurityError {}
#[cfg(any(feature = "office", feature = "pdf", feature = "iwork", feature = "ocr"))]
pub(crate) fn enforce_page_count(count: usize, max_pages: Option<usize>) -> Result<(), SecurityError> {
match max_pages {
Some(max) if count > max => Err(SecurityError::TooManyPages { count, max }),
_ => Ok(()),
}
}
#[cfg(any(
feature = "archives",
feature = "hwpx",
feature = "iwork",
feature = "office",
feature = "excel",
feature = "excel-wasm"
))]
#[cfg_attr(alef, alef(skip))]
pub struct ZipBombValidator {
limits: SecurityLimits,
}
#[cfg(any(
feature = "archives",
feature = "hwpx",
feature = "iwork",
feature = "office",
feature = "excel",
feature = "excel-wasm"
))]
impl ZipBombValidator {
const MEMBER_RATIO_FLOOR: u64 = 1024 * 1024;
pub(crate) fn new(limits: SecurityLimits) -> Self {
Self { limits }
}
pub(crate) fn validate<R: Read + Seek>(&self, archive: &mut zip::ZipArchive<R>) -> Result<(), SecurityError> {
let file_count = archive.len();
if file_count > self.limits.max_files_in_archive {
return Err(SecurityError::TooManyFiles {
count: file_count,
max: self.limits.max_files_in_archive,
});
}
let max_archive_size = self.limits.max_archive_size as u64;
let max_compression_ratio = self.limits.max_compression_ratio as f64;
let mut total_uncompressed: u64 = 0;
let mut total_compressed: u64 = 0;
for index in 0..file_count {
let (compressed_size, uncompressed_size) = match archive.by_index_raw(index) {
Ok(file) => (file.compressed_size(), file.size()),
Err(error) => {
return Err(SecurityError::UnreadableEntry {
index,
reason: error.to_string(),
});
}
};
total_uncompressed = total_uncompressed.saturating_add(uncompressed_size);
total_compressed = total_compressed.saturating_add(compressed_size);
if uncompressed_size > 0 && (compressed_size == 0 || uncompressed_size >= Self::MEMBER_RATIO_FLOOR) {
let ratio = if compressed_size == 0 {
f64::INFINITY
} else {
uncompressed_size as f64 / compressed_size as f64
};
if ratio > max_compression_ratio {
return Err(SecurityError::ZipBombDetected {
compressed_size,
uncompressed_size,
ratio,
});
}
}
if total_uncompressed > max_archive_size {
return Err(SecurityError::ArchiveTooLarge {
size: total_uncompressed,
max: self.limits.max_archive_size,
});
}
}
if total_compressed > 0 {
let ratio = total_uncompressed as f64 / total_compressed as f64;
if ratio > max_compression_ratio {
return Err(SecurityError::ZipBombDetected {
compressed_size: total_compressed,
uncompressed_size: total_uncompressed,
ratio,
});
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub(crate) struct StringGrowthValidator {
max_size: usize,
current_size: usize,
}
impl StringGrowthValidator {
pub(crate) fn new(max_size: usize) -> Self {
Self {
max_size,
current_size: 0,
}
}
pub(crate) fn check_append(&mut self, len: usize) -> Result<(), SecurityError> {
self.current_size = self.current_size.saturating_add(len);
if self.current_size > self.max_size {
Err(SecurityError::ContentTooLarge {
size: self.current_size,
max: self.max_size,
})
} else {
Ok(())
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct IterationValidator {
max_iterations: usize,
current_count: usize,
}
impl IterationValidator {
pub(crate) fn new(max_iterations: usize) -> Self {
Self {
max_iterations,
current_count: 0,
}
}
pub(crate) fn check_iteration(&mut self) -> Result<(), SecurityError> {
self.current_count = self.current_count.saturating_add(1);
if self.current_count > self.max_iterations {
Err(SecurityError::TooManyIterations {
count: self.current_count,
max: self.max_iterations,
})
} else {
Ok(())
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct DepthValidator {
max_depth: usize,
current_depth: usize,
}
impl DepthValidator {
pub(crate) fn new(max_depth: usize) -> Self {
Self {
max_depth,
current_depth: 0,
}
}
pub(crate) fn push(&mut self) -> Result<(), SecurityError> {
self.current_depth = self.current_depth.saturating_add(1);
if self.current_depth > self.max_depth {
Err(SecurityError::NestingTooDeep {
depth: self.current_depth,
max: self.max_depth,
})
} else {
Ok(())
}
}
pub(crate) fn pop(&mut self) {
if self.current_depth > 0 {
self.current_depth -= 1;
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct EntityValidator {
max_length: usize,
}
impl EntityValidator {
pub(crate) fn new(max_length: usize) -> Self {
Self { max_length }
}
pub(crate) fn validate(&self, content: &str) -> Result<(), SecurityError> {
if content.len() > self.max_length {
Err(SecurityError::EntityTooLong {
length: content.len(),
max: self.max_length,
})
} else {
Ok(())
}
}
#[cfg(any(feature = "xml", feature = "office"))]
pub(crate) fn check_attr(&self, _name: &str, value: &str) -> Result<(), SecurityError> {
self.validate(value)
}
}
#[derive(Debug, Clone)]
pub(crate) struct TableValidator {
max_cells: usize,
current_cells: usize,
}
impl TableValidator {
pub(crate) fn new(max_cells: usize) -> Self {
Self {
max_cells,
current_cells: 0,
}
}
pub(crate) fn add_cells(&mut self, count: usize) -> Result<(), SecurityError> {
self.current_cells = self.current_cells.saturating_add(count);
if self.current_cells > self.max_cells {
Err(SecurityError::TooManyCells {
cells: self.current_cells,
max: self.max_cells,
})
} else {
Ok(())
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct SecurityBudget {
pub(crate) depth: DepthValidator,
pub(crate) iteration: IterationValidator,
pub(crate) entity: EntityValidator,
pub(crate) growth: StringGrowthValidator,
pub(crate) table: TableValidator,
}
impl SecurityBudget {
pub(crate) fn from_limits(limits: &SecurityLimits) -> Self {
Self {
depth: DepthValidator::new(limits.max_xml_depth.min(limits.max_nesting_depth)),
iteration: IterationValidator::new(limits.max_iterations),
entity: EntityValidator::new(limits.max_entity_length),
growth: StringGrowthValidator::new(limits.max_content_size),
table: TableValidator::new(limits.max_table_cells),
}
}
#[cfg(feature = "iwork")]
pub(crate) fn for_iwork(limits: &SecurityLimits) -> Self {
let mut budget = Self::from_limits(limits);
budget.depth = DepthValidator::new(limits.max_nesting_depth);
budget
}
pub(crate) fn from_config(config: &crate::core::config::ExtractionConfig) -> Self {
let owned: SecurityLimits;
let limits: &SecurityLimits = match config.security_limits.as_ref() {
Some(l) => l,
None => {
owned = SecurityLimits::default();
&owned
}
};
Self::from_limits(limits)
}
#[cfg(any(feature = "xml", feature = "office"))]
pub(crate) fn with_defaults() -> Self {
Self::from_limits(&SecurityLimits::default())
}
pub(crate) fn step(&mut self) -> Result<(), SecurityError> {
self.iteration.check_iteration()
}
pub(crate) fn enter(&mut self) -> Result<(), SecurityError> {
self.depth.push()
}
pub(crate) fn leave(&mut self) {
self.depth.pop();
}
#[cfg(feature = "office")]
pub(crate) fn depth_limit(&self) -> usize {
self.depth.max_depth
}
pub(crate) fn account_text(&mut self, len: usize) -> Result<(), SecurityError> {
self.growth.check_append(len)
}
#[cfg(any(feature = "xml", feature = "office"))]
pub(crate) fn check_attr(&self, name: &str, value: &str) -> Result<(), SecurityError> {
self.entity.check_attr(name, value)
}
pub(crate) fn check_entity(&self, value: &str) -> Result<(), SecurityError> {
self.entity.validate(value)
}
pub(crate) fn add_cells(&mut self, count: usize) -> Result<(), SecurityError> {
self.table.add_cells(count)
}
}
#[cfg(any(feature = "office", test))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PathTraversalError {
EscapesRoot,
InvalidByte,
DriveOrUncPrefix,
EmptyResult,
}
#[cfg(any(feature = "office", test))]
impl std::fmt::Display for PathTraversalError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::EscapesRoot => write!(f, "path escapes the container root"),
Self::InvalidByte => write!(f, "path contains a NUL byte"),
Self::DriveOrUncPrefix => write!(f, "path carries a drive letter or UNC prefix"),
Self::EmptyResult => write!(f, "path resolves to no entry"),
}
}
}
#[cfg(any(feature = "office", test))]
impl std::error::Error for PathTraversalError {}
#[cfg(any(feature = "office", test))]
pub(crate) fn resolve_container_entry(base: &str, target: &str) -> Result<String, PathTraversalError> {
if target.contains('\0') {
return Err(PathTraversalError::InvalidByte);
}
let normalized_target = target.replace('\\', "/");
if is_drive_or_unc_prefixed(&normalized_target) {
return Err(PathTraversalError::DriveOrUncPrefix);
}
let mut stack: Vec<&str> = Vec::new();
let effective: &str = match normalized_target.strip_prefix('/') {
Some(root_relative) => root_relative,
None => {
for segment in base.split('/') {
push_segment(&mut stack, segment)?;
}
normalized_target.as_str()
}
};
for segment in effective.split('/') {
push_segment(&mut stack, segment)?;
}
if stack.is_empty() {
return Err(PathTraversalError::EmptyResult);
}
Ok(stack.join("/"))
}
#[cfg(any(feature = "office", test))]
fn push_segment<'a>(stack: &mut Vec<&'a str>, segment: &'a str) -> Result<(), PathTraversalError> {
match segment {
"" | "." => {}
".." => {
if stack.pop().is_none() {
return Err(PathTraversalError::EscapesRoot);
}
}
_ => stack.push(segment),
}
Ok(())
}
#[cfg(any(feature = "office", test))]
fn is_drive_or_unc_prefixed(path: &str) -> bool {
let bytes = path.as_bytes();
path.starts_with("//") || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
}
#[cfg(test)]
mod tests;