release: v0.7.1 — Security-Patch (R-01 bis R-06)
Sanctum Release / Build & Release (Windows x86_64) (push) Waiting to run
Sanctum Release / Build & Release (Windows x86_64) (push) Waiting to run
- R-01: Bereinigung verbliebener Restbehauptungen in Doku und Code (LEGAL.md, README.md, QUICKSTART.md, main.rs, crypto/storage/recovery/verify.rs) - R-02: Lückenloser Carrier-Schutz in SanctumFs::copy (Quelle & Ziel) und sync (Pull-Skip & Push-Schutz) - R-03: Shared Dateinamen-Validierung (validate_node_name) in pathutil.rs, durchgesetzt in storage.rs und vfs.rs - R-04: Fail-closed Release-Packaging & obligatorische Minisign-Signatur in CI (.gitea/workflows/release.yaml) und Scripts - R-05: Session-Token Beseitigung im argv: In-Process Win32 WNetAddConnection2W/WNetCancelConnection2W, kein gio argv-Token, Multi-Auth HTTP Middleware (Basic Auth, X-Sanctum-Token, Path-Fallback) mit 401 WWW-Authenticate - R-06: Sofortiges Löschen von SANCTUM_RECOVERY_KEY aus der Prozessumgebung
This commit is contained in:
@@ -1094,10 +1094,8 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
||||
|
||||
let fs = SanctumFs::new(db.clone(), dek.clone(), FORMAT_VERSION);
|
||||
let session_token = "deadbeefcafebabe0123456789abcdef";
|
||||
let token_path_prefix = format!("/{}", session_token);
|
||||
|
||||
let dav_server = DavHandler::builder()
|
||||
.strip_prefix(token_path_prefix.clone())
|
||||
.filesystem(Box::new(fs))
|
||||
.locksystem(FakeLs::new())
|
||||
.build_handler();
|
||||
@@ -1109,11 +1107,11 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
||||
let server_handle = tokio::spawn(serve_webdav_loop(
|
||||
listener,
|
||||
dav_server,
|
||||
token_path_prefix.clone(),
|
||||
session_token.to_string(),
|
||||
shutdown_rx,
|
||||
));
|
||||
|
||||
// 3. Angriffstest A: Unbefugter Zugriff auf Root ohne Session-Token -> 403 Forbidden
|
||||
// 3. Angriffstest A: Unbefugter Zugriff auf Root ohne Session-Token -> 401 Unauthorized mit WWW-Authenticate
|
||||
{
|
||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||
let req = format!("GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", addr.port());
|
||||
@@ -1122,8 +1120,13 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
||||
stream.read_to_end(&mut resp).await.unwrap();
|
||||
let resp_str = String::from_utf8_lossy(&resp);
|
||||
assert!(
|
||||
resp_str.starts_with("HTTP/1.1 403 Forbidden"),
|
||||
"Anfrage ohne Session-Token muss mit 403 Forbidden abgewiesen werden, erhalten:\n{}",
|
||||
resp_str.starts_with("HTTP/1.1 401 Unauthorized"),
|
||||
"Anfrage ohne Session-Token muss mit 401 Unauthorized abgewiesen werden, erhalten:\n{}",
|
||||
resp_str
|
||||
);
|
||||
assert!(
|
||||
resp_str.to_lowercase().contains("www-authenticate: basic realm=\"sanctum\""),
|
||||
"401-Antwort muss WWW-Authenticate Header enthalten, erhalten:\n{}",
|
||||
resp_str
|
||||
);
|
||||
}
|
||||
@@ -1143,7 +1146,7 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Legitimer Zugriff: Korrektes Session-Token & Loopback-Host -> 200 OK
|
||||
// 5. Legitimer Zugriff: Korrektes Session-Token via Path-Prefix Fallback -> 200 OK
|
||||
{
|
||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||
let req = format!("OPTIONS /{}/ HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", session_token, addr.port());
|
||||
@@ -1162,6 +1165,23 @@ async fn test_webdav_loopback_session_token_and_host_validation() {
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Legitimer Zugriff: HTTP Basic Auth (R-05) -> 200 OK
|
||||
{
|
||||
use base64::Engine;
|
||||
let auth_hdr = format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("sanctum:{}", session_token)));
|
||||
let mut stream = TcpStream::connect(addr).await.unwrap();
|
||||
let req = format!("OPTIONS / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nAuthorization: {}\r\nConnection: close\r\n\r\n", addr.port(), auth_hdr);
|
||||
stream.write_all(req.as_bytes()).await.unwrap();
|
||||
let mut resp = Vec::new();
|
||||
stream.read_to_end(&mut resp).await.unwrap();
|
||||
let resp_str = String::from_utf8_lossy(&resp);
|
||||
assert!(
|
||||
resp_str.starts_with("HTTP/1.1 200 OK"),
|
||||
"Legitime OPTIONS-Anfrage mit Basic Auth muss 200 OK liefern, erhalten:\n{}",
|
||||
resp_str
|
||||
);
|
||||
}
|
||||
|
||||
// Server ordnungsgemäß beenden & aufräumen
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = server_handle.await;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::path::PathBuf;
|
||||
use std::path::PathBuf;
|
||||
use sanctum::crypto::{
|
||||
derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, MIN_MEMORY_COST_KIB,
|
||||
MIN_TIME_COST,
|
||||
@@ -82,3 +82,34 @@ fn test_wal_and_shm_cleanup_on_close() {
|
||||
|
||||
let _ = std::fs::remove_file(&container_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mount_security_multi_auth_and_no_token_in_url() {
|
||||
use sanctum::mount::{check_basic_auth, check_token_header, strip_path_prefix};
|
||||
|
||||
let session_token = "4f8a12bc90de45f187a23456789abcde";
|
||||
let port = 8443;
|
||||
let remote_url = format!("http://127.0.0.1:{}/", port);
|
||||
|
||||
// R-05: Die Remote-URL für Mount-Befehle darf niemals das Session-Token enthalten!
|
||||
assert!(!remote_url.contains(session_token), "Remote URL darf niemals das Session-Token enthalten");
|
||||
|
||||
// 1. Basic Auth Prüfung
|
||||
use base64::Engine;
|
||||
let auth_header = format!(
|
||||
"Basic {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("sanctum:{}", session_token))
|
||||
);
|
||||
assert!(check_basic_auth(&auth_header, session_token));
|
||||
|
||||
// 2. Token Header Prüfung
|
||||
let mut headers = hyper::HeaderMap::new();
|
||||
headers.insert("X-Sanctum-Token", session_token.parse().unwrap());
|
||||
assert!(check_token_header(&headers, session_token));
|
||||
|
||||
// 3. Path-Prefix Fallback
|
||||
let prefix = format!("/{}", session_token);
|
||||
let uri: hyper::Uri = format!("http://127.0.0.1:8443/{}/test.txt", session_token).parse().unwrap();
|
||||
let stripped = strip_path_prefix(&uri, &prefix).expect("Strip prefix");
|
||||
assert_eq!(stripped.path(), "/test.txt");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use sanctum::sync::validate_node_name;
|
||||
use sanctum::sync::validate_node_name;
|
||||
|
||||
#[test]
|
||||
fn test_validate_node_name_rejections() {
|
||||
@@ -60,3 +60,40 @@ fn test_validate_node_name_accepted() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_create_and_rename_reject_invalid_names() {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("sanctum_test_pathutil_{}.sanctum", std::process::id()));
|
||||
if path.exists() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
let salt = sanctum::crypto::generate_salt();
|
||||
let kdf_params = sanctum::crypto::KdfParams {
|
||||
memory_cost: sanctum::crypto::MIN_MEMORY_COST_KIB,
|
||||
time_cost: sanctum::crypto::MIN_TIME_COST,
|
||||
parallelism: 1,
|
||||
};
|
||||
let kek = sanctum::crypto::derive_kek("Pass1234!", &salt, &kdf_params).unwrap();
|
||||
let dek = sanctum::crypto::generate_dek();
|
||||
let (wrapped, nonce, tag) = sanctum::crypto::wrap_dek(&kek, &dek).unwrap();
|
||||
|
||||
let db = sanctum::storage::Database::open(&path).unwrap();
|
||||
db.init_schema(&salt, &kdf_params, &wrapped, &nonce, &tag).unwrap();
|
||||
|
||||
// create_node_in_vault mit ungültigem Namen muss scheitern
|
||||
assert!(db.create_node_in_vault(0, 1, "..", false, &dek).is_err());
|
||||
assert!(db.create_node_in_vault(0, 1, "CON", false, &dek).is_err());
|
||||
assert!(db.create_node_in_vault(0, 1, "sub/dir", false, &dek).is_err());
|
||||
|
||||
// Gültige Datei erstellen
|
||||
let valid_node = db.create_node_in_vault(0, 1, "valid.txt", false, &dek).unwrap();
|
||||
|
||||
// rename_node_in_vault mit ungültigem Namen muss scheitern
|
||||
assert!(db.rename_node_in_vault(valid_node.id, 1, "..", 0, &dek).is_err());
|
||||
assert!(db.rename_node_in_vault(valid_node.id, 1, "AUX", 0, &dek).is_err());
|
||||
assert!(db.rename_node_in_vault(valid_node.id, 1, "bad\\name", 0, &dek).is_err());
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
@@ -109,5 +109,71 @@ fn test_carrier_protection_in_storage_and_sync() {
|
||||
let carrier_rec = db.get_node_by_id(carrier_node_id).unwrap();
|
||||
assert!(carrier_rec.is_some(), "Carrier-Knoten muss nach Sync unversehrt vorhanden sein");
|
||||
|
||||
// 5. R-02: sync_single_file_to_host muss Carrier-Pull strikt ablehnen
|
||||
let carrier_node = db.get_node_by_id(carrier_node_id).unwrap().unwrap();
|
||||
let local_dest_file = std::env::temp_dir().join(format!("carrier_leak_{}.iso", std::process::id()));
|
||||
let pull_single_res = sanctum::sync::sync_single_file_to_host(
|
||||
&db,
|
||||
0,
|
||||
&dek_0,
|
||||
sanctum::crypto::FORMAT_VERSION,
|
||||
&carrier_node,
|
||||
&local_dest_file,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert!(pull_single_res.is_err(), "sync_single_file_to_host auf Carrier muss fehlschlagen");
|
||||
assert!(!local_dest_file.exists(), "Trägerdatei darf niemals auf den Host geschrieben werden");
|
||||
|
||||
// 6. R-02: run_sync mit Pull muss die Trägerdatei überspringen
|
||||
let local_pull_dir = std::env::temp_dir().join(format!("sanctum_pull_test_{}", std::process::id()));
|
||||
let sync_options = sanctum::sync::SyncOptions {
|
||||
direction: sanctum::sync::SyncDirection::Pull,
|
||||
delete: false,
|
||||
dry_run: false,
|
||||
checksum: false,
|
||||
exclude_patterns: Vec::new(),
|
||||
quiet: true,
|
||||
};
|
||||
let pull_res = sanctum::sync::run_sync(
|
||||
&db,
|
||||
0,
|
||||
&dek_0,
|
||||
sanctum::crypto::FORMAT_VERSION,
|
||||
"/",
|
||||
&local_pull_dir.to_string_lossy(),
|
||||
&sync_options,
|
||||
);
|
||||
assert!(pull_res.is_ok(), "run_sync pull muss erfolgreich durchlaufen");
|
||||
let pulled_carrier = local_pull_dir.join(carrier_name);
|
||||
assert!(!pulled_carrier.exists(), "Trägerdatei darf bei recursive pull nicht auf den Host kopiert werden");
|
||||
let _ = std::fs::remove_dir_all(&local_pull_dir);
|
||||
|
||||
// 7. R-02: VFS Copy Schutz
|
||||
let fs = sanctum::vfs::SanctumFs::with_carrier(
|
||||
db.clone(),
|
||||
dek_0.clone(),
|
||||
Some(dek_0.clone()),
|
||||
Some(carrier_node_id),
|
||||
sanctum::crypto::FORMAT_VERSION,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
use dav_server::davpath::DavPath;
|
||||
use dav_server::fs::DavFileSystem;
|
||||
// Copy Quelle = Carrier
|
||||
let from_carrier = DavPath::new(&format!("/{}", carrier_name)).unwrap();
|
||||
let to_copy = DavPath::new("/carrier_copy.iso").unwrap();
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let copy_src_res = rt.block_on(fs.copy(&from_carrier, &to_copy));
|
||||
assert!(copy_src_res.is_err(), "SanctumFs::copy mit Carrier als Quelle muss FsError::Forbidden liefern");
|
||||
|
||||
// Copy Ziel = Carrier
|
||||
let _regular_file = db.create_node_in_vault(0, 1, "regular.txt", false, &dek_0).unwrap();
|
||||
let from_regular = DavPath::new("/regular.txt").unwrap();
|
||||
let to_carrier = DavPath::new(&format!("/{}", carrier_name)).unwrap();
|
||||
let copy_dst_res = rt.block_on(fs.copy(&from_regular, &to_carrier));
|
||||
assert!(copy_dst_res.is_err(), "SanctumFs::copy mit Carrier als Ziel muss FsError::Forbidden liefern");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user