release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)
Sanctum Release / Build & Test (Windows x86_64) (push) Canceled after 0s
Sanctum Release / Sign & Release (push) Canceled after 0s

- SA-01: Container-DoS / KDF-Amplification Schutz mit Pre-KDF Validierung, max 2 Slots (nur 0 und 1), Slot 0 Pflicht und strikten BLOB-Laengen
- SA-02: Release-Signierung in CI entkoppelt (getrennte build und sign-and-release Jobs, Secret-Isolation)
- SA-03: Pinned Download-Integritaet fuer minisign.exe in CI via SHA-256
- SA-04: Immutable Action-Pinning (@sha) und Toolchain-Pinning (1.85.0) in CI
- SA-05: Session-Token vollstaendig aus URIs verbannt (403 Forbidden bei Vorkommen im Pfad/Query)
- SA-06: Constant-Time Token- und Auth-Vergleiche via subtle::ConstantTimeEq
- SA-07: Dokumentations-Klarstellung bzgl. logischem Shredding vs. physischer SSD/FTL/CoW-Persistenz
This commit is contained in:
2026-09-18 23:40:35 +02:00
parent 1cdb30147b
commit fba7f305e3
28 changed files with 3048 additions and 968 deletions
+75 -62
View File
@@ -26,14 +26,8 @@ use crate::storage::{Database, NodeRecord};
pub fn is_leak_file(filename: &str) -> bool {
let lower = filename.trim().to_ascii_lowercase();
match lower.as_str() {
"thumbs.db"
| "ehthumbs.db"
| "ehthumbs_vista.db"
| "desktop.ini"
| "folder.jpg"
| "albumartsmall.jpg"
| "autorun.inf"
| ".ds_store" => true,
"thumbs.db" | "ehthumbs.db" | "ehthumbs_vista.db" | "desktop.ini" | "folder.jpg"
| "albumartsmall.jpg" | "autorun.inf" | ".ds_store" => true,
_ => {
if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini"))
{
@@ -167,7 +161,6 @@ impl SanctumFile {
self.last_activity.store(now, Ordering::Relaxed);
}
/// Schreibt den aktuell im RAM gehaltenen Chunk verschlüsselt in die SQLite-Datenbank zurück
/// und aktualisiert Dateigröße und Modifikationszeitstempel atomar in einer Transaktion (CHAOS-01).
/// Bei Fehlern (z. B. Disk Full) wird der Cache sauber invalidiert (CHAOS-03).
@@ -179,10 +172,12 @@ impl SanctumFile {
if let Some((idx, ref data, true)) = self.cached_chunk {
let (ciphertext, nonce, tag) =
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(|e| {
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
FsError::GeneralFailure
})?;
encrypt_chunk(&self.dek, self.node_id, idx, data, self.format_version).map_err(
|e| {
error!("Verschlüsselungsfehler beim Chunk-Flush: {e}");
FsError::GeneralFailure
},
)?;
if let Err(e) = self.db.write_chunk_and_update_size(
self.node_id,
@@ -207,7 +202,10 @@ impl SanctumFile {
self.meta.modified_at = UNIX_EPOCH + Duration::from_secs(now);
} else if self.meta.size != self.file_size {
// Falls kein Chunk dirty war, aber sich z. B. die Dateigröße durch Truncate geändert hat
if let Err(e) = self.db.update_node_size_and_time(self.node_id, self.file_size, now) {
if let Err(e) = self
.db
.update_node_size_and_time(self.node_id, self.file_size, now)
{
error!("Fehler beim Aktualisieren der Knotengröße: {e}");
return Err(FsError::GeneralFailure);
}
@@ -263,7 +261,10 @@ impl SanctumFile {
impl Drop for SanctumFile {
fn drop(&mut self) {
if let Err(e) = self.flush_cached_chunk_and_size() {
warn!("Fehler beim automatischen Flush im SanctumFile::drop: {:?}", e);
warn!(
"Fehler beim automatischen Flush im SanctumFile::drop: {:?}",
e
);
}
if let Some((_, ref mut data, _)) = self.cached_chunk {
data.zeroize();
@@ -354,7 +355,12 @@ impl DavFile for SanctumFile {
}
// Wenn der Chunk exakt 1 MB erreicht hat, sofort flushen, um RAM zu schonen
if self.cached_chunk.as_ref().map(|(_, d, _)| d.len() >= CHUNK_SIZE).unwrap_or(false) {
if self
.cached_chunk
.as_ref()
.map(|(_, d, _)| d.len() >= CHUNK_SIZE)
.unwrap_or(false)
{
self.flush_cached_chunk_and_size()?;
}
@@ -633,14 +639,19 @@ impl DavFileSystem for SanctumFs {
}
self.touch();
debug!("VFS open aufgerufen: path='{}', options={:?}", path_str, options);
debug!(
"VFS open aufgerufen: path='{}', options={:?}",
path_str, options
);
let existing_node = self.resolve_path(&path_str)?;
let node = match existing_node {
Some(n) => {
// Schutz der Trägerdatei im Decoy Vault: Keine Schreib- oder Truncate-Operationen erlaubt!
if self.carrier_node_id == Some(n.id) && (options.write || options.truncate || options.append) {
if self.carrier_node_id == Some(n.id)
&& (options.write || options.truncate || options.append)
{
return Err(FsError::Forbidden);
}
@@ -674,9 +685,7 @@ impl DavFileSystem for SanctumFs {
}
None => {
if options.create || options.create_new {
let parent = self
.resolve_path(parent_path)?
.ok_or(FsError::NotFound)?;
let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?;
if !parent.is_dir {
return Err(FsError::Forbidden);
@@ -712,9 +721,7 @@ impl DavFileSystem for SanctumFs {
Box::pin(async move {
let path_str = Self::path_to_str(path);
let node = self
.resolve_path(&path_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
if !node.is_dir {
return Err(FsError::Forbidden);
@@ -749,9 +756,7 @@ impl DavFileSystem for SanctumFs {
Box::pin(async move {
let path_str = Self::path_to_str(path);
let node = self
.resolve_path(&path_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
let meta = SanctumMetaData {
is_dir: node.is_dir,
@@ -789,9 +794,7 @@ impl DavFileSystem for SanctumFs {
return Err(FsError::Exists);
}
let parent = self
.resolve_path(parent_path)?
.ok_or(FsError::NotFound)?;
let parent = self.resolve_path(parent_path)?.ok_or(FsError::NotFound)?;
if !parent.is_dir {
return Err(FsError::Forbidden);
@@ -811,9 +814,7 @@ impl DavFileSystem for SanctumFs {
Box::pin(async move {
self.touch();
let path_str = Self::path_to_str(path);
let node = self
.resolve_path(&path_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
if !node.is_dir {
return Err(FsError::Forbidden);
@@ -826,7 +827,11 @@ impl DavFileSystem for SanctumFs {
// Schutz der Trägerdatei im Decoy Vault: Verzeichnis darf nicht gelöscht werden, wenn es den Carrier enthält!
if let Some(carrier_id) = self.carrier_node_id {
if self.db.is_descendant_of(carrier_id, node.id).map_err(|_| FsError::GeneralFailure)? {
if self
.db
.is_descendant_of(carrier_id, node.id)
.map_err(|_| FsError::GeneralFailure)?
{
return Err(FsError::Forbidden);
}
}
@@ -847,9 +852,7 @@ impl DavFileSystem for SanctumFs {
Box::pin(async move {
self.touch();
let path_str = Self::path_to_str(path);
let node = self
.resolve_path(&path_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&path_str)?.ok_or(FsError::NotFound)?;
if node.is_dir {
return Err(FsError::Forbidden);
@@ -868,11 +871,7 @@ impl DavFileSystem for SanctumFs {
})
}
fn rename<'a>(
&'a self,
from: &'a DavPath,
to: &'a DavPath,
) -> FsFuture<'a, ()> {
fn rename<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
if let Some(ref cfs) = self.carrier_fs {
return cfs.rename(from, to);
}
@@ -882,9 +881,7 @@ impl DavFileSystem for SanctumFs {
let from_str = Self::path_to_str(from);
let to_str = Self::path_to_str(to);
let node = self
.resolve_path(&from_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
// Schutz der Trägerdatei im Decoy Vault: Umbenennen verboten!
if self.carrier_node_id == Some(node.id) {
@@ -925,19 +922,13 @@ impl DavFileSystem for SanctumFs {
})
}
fn copy<'a>(
&'a self,
from: &'a DavPath,
to: &'a DavPath,
) -> FsFuture<'a, ()> {
fn copy<'a>(&'a self, from: &'a DavPath, to: &'a DavPath) -> FsFuture<'a, ()> {
Box::pin(async move {
self.touch();
let from_str = Self::path_to_str(from);
let to_str = Self::path_to_str(to);
let node = self
.resolve_path(&from_str)?
.ok_or(FsError::NotFound)?;
let node = self.resolve_path(&from_str)?.ok_or(FsError::NotFound)?;
if node.is_dir {
return Err(FsError::NotImplemented);
@@ -991,9 +982,14 @@ impl DavFileSystem for SanctumFs {
)
.map_err(|_| FsError::GeneralFailure)?;
let (new_ct, new_nonce, new_tag) =
encrypt_chunk(&self.dek, dest_node.id, idx, &plaintext, self.format_version)
.map_err(|_| FsError::GeneralFailure)?;
let (new_ct, new_nonce, new_tag) = encrypt_chunk(
&self.dek,
dest_node.id,
idx,
&plaintext,
self.format_version,
)
.map_err(|_| FsError::GeneralFailure)?;
self.db
.write_chunk(dest_node.id, idx, &new_nonce, &new_tag, &new_ct)
@@ -1025,7 +1021,9 @@ impl DavFileSystem for SanctumFs {
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION};
use crate::crypto::{
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, FORMAT_VERSION,
};
use dav_server::fs::OpenOptions;
use futures_util::StreamExt;
@@ -1078,7 +1076,8 @@ mod tests {
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new() -> Self {
let p = std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::<u64>()));
let p =
std::env::temp_dir().join(format!("sanctum_test_{}", rand::random::<u64>()));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
@@ -1137,7 +1136,10 @@ mod tests {
// read_dir mit anti_leak = true darf Thumbs.db NICHT anzeigen
let root_path = DavPath::new("/").unwrap();
let mut stream = fs_shielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
let mut stream = fs_shielded
.read_dir(&root_path, ReadDirMeta::None)
.await
.unwrap();
let mut names = Vec::new();
while let Some(entry) = stream.next().await {
let entry = entry.unwrap();
@@ -1154,7 +1156,10 @@ mod tests {
FORMAT_VERSION,
false,
);
let mut stream_unshielded = fs_unshielded.read_dir(&root_path, ReadDirMeta::None).await.unwrap();
let mut stream_unshielded = fs_unshielded
.read_dir(&root_path, ReadDirMeta::None)
.await
.unwrap();
let mut names_unshielded = Vec::new();
while let Some(entry) = stream_unshielded.next().await {
let entry = entry.unwrap();
@@ -1263,7 +1268,13 @@ mod tests {
// 1. Null-Byte im Pfad
let null_path = DavPath::new("/bad\0file.txt");
assert!(null_path.is_err() || fs.open(&null_path.unwrap(), OpenOptions::default()).await.is_err());
assert!(
null_path.is_err()
|| fs
.open(&null_path.unwrap(), OpenOptions::default())
.await
.is_err()
);
assert!(validate_path_safety("/bad\0file.txt").is_err());
// 2. Steuerzeichen < 0x20
@@ -1288,7 +1299,9 @@ mod tests {
// 1. Schreibe 500 Bytes
let payload = Bytes::from(vec![42u8; 500]);
file.write_buf(Box::new(std::io::Cursor::new(payload))).await.unwrap();
file.write_buf(Box::new(std::io::Cursor::new(payload)))
.await
.unwrap();
// 2. Expliziter Flush: muss Chunk & Dateigröße atomar persistieren
file.flush().await.unwrap();