#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum UploadError {
#[error("invalid upload policy: {0}")]
InvalidPolicy(String),
#[error("invalid upload tenant")]
InvalidTenant,
#[error("invalid upload filename")]
InvalidFileName,
#[error("upload size is outside policy")]
InvalidSize,
#[error("upload media type is denied")]
MediaTypeDenied,
#[error("active upload content is denied")]
ActiveContentDenied,
#[error("upload scanner rejected the object")]
MalwareDetected,
#[error("upload scanner did not return a clean verdict")]
ScanUnavailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum UploadKind {
Png,
Jpeg,
Pdf,
Mp4,
WebM,
PlainText,
}
impl UploadKind {
pub fn media_type(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Pdf => "application/pdf",
Self::Mp4 => "video/mp4",
Self::WebM => "video/webm",
Self::PlainText => "text/plain",
}
}
pub fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpg",
Self::Pdf => "pdf",
Self::Mp4 => "mp4",
Self::WebM => "webm",
Self::PlainText => "txt",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UploadPolicy {
max_bytes: usize,
allowed: Vec<UploadKind>,
}
impl UploadPolicy {
pub fn try_new(
max_bytes: usize,
allowed: impl IntoIterator<Item = UploadKind>,
) -> Result<Self, UploadError> {
let mut allowed = allowed.into_iter().collect::<Vec<_>>();
allowed.sort_unstable();
allowed.dedup();
if !(1..=100 * 1024 * 1024).contains(&max_bytes) || allowed.is_empty() {
return Err(UploadError::InvalidPolicy(
"size and at least one allowed type are required".to_string(),
));
}
Ok(Self { max_bytes, allowed })
}
pub fn max_bytes(&self) -> usize {
self.max_bytes
}
pub fn allowed_kinds(&self) -> &[UploadKind] {
&self.allowed
}
pub fn admit(
&self,
tenant_id: &str,
file_name: &str,
declared_media_type: &str,
bytes: &[u8],
) -> Result<QuarantinedUpload, UploadError> {
if !valid_tenant_id(tenant_id) {
return Err(UploadError::InvalidTenant);
}
validate_file_name(file_name)?;
if bytes.is_empty() || bytes.len() > self.max_bytes {
return Err(UploadError::InvalidSize);
}
let kind = detect_kind(bytes)?;
if !self.allowed.contains(&kind)
|| declared_media_type.trim().to_ascii_lowercase() != kind.media_type()
|| !extension_matches(file_name, kind)
{
return Err(UploadError::MediaTypeDenied);
}
let object_id = uuid::Uuid::new_v4();
let quarantine_key = format!("quarantine/{tenant_id}/{object_id}.{}", kind.extension());
let digest = ring::digest::digest(&ring::digest::SHA256, bytes);
Ok(QuarantinedUpload {
quarantine_key,
display_name: file_name.to_string(),
kind,
byte_len: bytes.len(),
sha256_hex: encode_hex(digest.as_ref()),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct QuarantinedUpload {
quarantine_key: String,
display_name: String,
kind: UploadKind,
byte_len: usize,
sha256_hex: String,
}
impl QuarantinedUpload {
pub fn quarantine_key(&self) -> &str {
&self.quarantine_key
}
pub fn display_name(&self) -> &str {
&self.display_name
}
pub fn kind(&self) -> UploadKind {
self.kind
}
pub fn byte_len(&self) -> usize {
self.byte_len
}
pub fn sha256_hex(&self) -> &str {
&self.sha256_hex
}
pub fn release(self, verdict: ScanVerdict) -> Result<ReleasedUpload, UploadError> {
match verdict {
ScanVerdict::Clean {
engine,
evidence_id,
} if valid_evidence(&engine) && valid_evidence(&evidence_id) => Ok(ReleasedUpload {
object_key: self.quarantine_key.replacen("quarantine/", "accepted/", 1),
display_name: self.display_name,
kind: self.kind,
byte_len: self.byte_len,
sha256_hex: self.sha256_hex,
scanner_engine: engine,
scan_evidence_id: evidence_id,
}),
ScanVerdict::Infected { .. } => Err(UploadError::MalwareDetected),
ScanVerdict::Unavailable { .. } | ScanVerdict::Clean { .. } => {
Err(UploadError::ScanUnavailable)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ScanVerdict {
Clean {
engine: String,
evidence_id: String,
},
Infected {
engine: String,
signature: String,
},
Unavailable {
engine: String,
reason: String,
},
}
pub trait UploadScanner {
fn scan(&self, upload: &QuarantinedUpload, bytes: &[u8]) -> Result<ScanVerdict, UploadError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct OfflineMockScanner;
impl UploadScanner for OfflineMockScanner {
fn scan(&self, upload: &QuarantinedUpload, bytes: &[u8]) -> Result<ScanVerdict, UploadError> {
if bytes.len() != upload.byte_len {
return Err(UploadError::InvalidSize);
}
let digest = ring::digest::digest(&ring::digest::SHA256, bytes);
if encode_hex(digest.as_ref()) != upload.sha256_hex {
return Err(UploadError::ScanUnavailable);
}
if bytes.windows(5).any(|window| window == b"EICAR") {
return Ok(ScanVerdict::Infected {
engine: "offline-mock-v1".to_string(),
signature: "deterministic-eicar-marker".to_string(),
});
}
Ok(ScanVerdict::Clean {
engine: "offline-mock-v1".to_string(),
evidence_id: upload.sha256_hex.clone(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ReleasedUpload {
pub object_key: String,
pub display_name: String,
pub kind: UploadKind,
pub byte_len: usize,
pub sha256_hex: String,
pub scanner_engine: String,
pub scan_evidence_id: String,
}
fn valid_tenant_id(value: &str) -> bool {
crate::security::validate_tenant_id(value).is_ok()
}
fn validate_file_name(value: &str) -> Result<(), UploadError> {
if value.is_empty()
|| value.len() > 255
|| value == "."
|| value == ".."
|| value.contains(['/', '\\', '\0'])
|| value.chars().any(char::is_control)
{
return Err(UploadError::InvalidFileName);
}
Ok(())
}
fn extension_matches(file_name: &str, kind: UploadKind) -> bool {
let extension = file_name.rsplit_once('.').map(|(_, value)| value);
match kind {
UploadKind::Jpeg => extension.is_some_and(|value| {
value.eq_ignore_ascii_case("jpg") || value.eq_ignore_ascii_case("jpeg")
}),
_ => extension.is_some_and(|value| value.eq_ignore_ascii_case(kind.extension())),
}
}
fn detect_kind(bytes: &[u8]) -> Result<UploadKind, UploadError> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return Ok(UploadKind::Png);
}
if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
return Ok(UploadKind::Jpeg);
}
if bytes.starts_with(b"%PDF-") {
return Ok(UploadKind::Pdf);
}
if bytes.len() >= 12 && bytes.get(4..8) == Some(b"ftyp") {
return Ok(UploadKind::Mp4);
}
if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
return Ok(UploadKind::WebM);
}
let text = std::str::from_utf8(bytes).map_err(|_| UploadError::MediaTypeDenied)?;
if text.chars().any(|character| {
character == '\0' || (character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
}) {
return Err(UploadError::MediaTypeDenied);
}
if starts_with_markup(text) || text.to_ascii_lowercase().contains("javascript:") {
return Err(UploadError::ActiveContentDenied);
}
Ok(UploadKind::PlainText)
}
fn starts_with_markup(text: &str) -> bool {
let mut characters = text
.trim_start_matches(|character: char| {
character.is_whitespace()
|| matches!(character, '\u{FEFF}' | '\u{200B}'..='\u{200D}' | '\u{2060}')
})
.chars();
characters.next() == Some('<')
&& characters.next().is_some_and(|character| {
character.is_ascii_alphabetic() || matches!(character, '!' | '?' | '/')
})
}
fn valid_evidence(value: &str) -> bool {
!value.is_empty() && value.len() <= 256 && !value.chars().any(char::is_control)
}
fn encode_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
output
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn text_policy() -> UploadPolicy {
UploadPolicy::try_new(1_024, [UploadKind::PlainText]).expect("test upload policy")
}
#[test]
fn upload_policy_rejects_spoofing_active_content_and_unsafe_names() {
let policy = text_policy();
assert!(matches!(
policy.admit(
"school-1",
"note.txt",
"text/plain",
b"<svg onload=alert(1)>"
),
Err(UploadError::ActiveContentDenied)
));
assert!(matches!(
policy.admit("school-1", "../note.txt", "text/plain", b"safe"),
Err(UploadError::InvalidFileName)
));
assert!(matches!(
policy.admit("school-1", "note.pdf", "application/pdf", b"plain text"),
Err(UploadError::MediaTypeDenied)
));
assert!(matches!(
policy.admit("../school", "note.txt", "text/plain", b"safe"),
Err(UploadError::InvalidTenant)
));
for dot_only in [".", "..", "..."] {
assert!(matches!(
policy.admit(dot_only, "note.txt", "text/plain", b"safe"),
Err(UploadError::InvalidTenant)
));
}
}
#[test]
fn quarantine_requires_a_clean_digest_bound_scan() {
let policy = text_policy();
let bytes = b"bounded learner submission";
let admitted = policy
.admit("school-1", "answer.txt", "text/plain", bytes)
.expect("admitted text");
assert!(
admitted
.quarantine_key()
.starts_with("quarantine/school-1/")
);
let verdict = OfflineMockScanner
.scan(&admitted, bytes)
.expect("offline clean scan");
let released = admitted.release(verdict).expect("clean release");
assert!(released.object_key.starts_with("accepted/school-1/"));
assert_eq!(released.byte_len, bytes.len());
let infected_bytes = b"EICAR deterministic fixture";
let infected = policy
.admit("school-1", "fixture.txt", "text/plain", infected_bytes)
.expect("admitted infected fixture");
let verdict = OfflineMockScanner
.scan(&infected, infected_bytes)
.expect("offline infected scan");
assert!(matches!(
infected.release(verdict),
Err(UploadError::MalwareDetected)
));
}
#[test]
fn unavailable_or_tampered_scan_fails_closed() {
let admitted = text_policy()
.admit("school-1", "answer.txt", "text/plain", b"original")
.expect("admitted text");
assert!(matches!(
OfflineMockScanner.scan(&admitted, b"changed"),
Err(UploadError::InvalidSize | UploadError::ScanUnavailable)
));
assert!(matches!(
admitted.release(ScanVerdict::Unavailable {
engine: "scanner".to_string(),
reason: "offline".to_string(),
}),
Err(UploadError::ScanUnavailable)
));
}
}
#[cfg(test)]
#[path = "uploads_contract_tests.rs"]
mod contract_tests;