Skip to content

Commit 61ea449

Browse files
Merge pull request tonyantony300#113 from Retengart/security-fixes
fix(send): skip invalid path components instead of failing share
2 parents 0e7f714 + f8986d8 commit 61ea449

File tree

5 files changed

+171
-26
lines changed

5 files changed

+171
-26
lines changed

sendme/src/core/receive.rs

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,13 +299,86 @@ fn get_export_path(root: &Path, name: &str) -> anyhow::Result<PathBuf> {
299299
}
300300

301301
fn validate_path_component(component: &str) -> anyhow::Result<()> {
302-
anyhow::ensure!(
303-
!component.contains('/'),
304-
"path components must not contain the only correct path separator, /"
305-
);
302+
anyhow::ensure!(!component.is_empty(), "empty path component");
303+
anyhow::ensure!(!component.contains('/'), "contains /");
304+
anyhow::ensure!(!component.contains('\\'), "contains \\");
305+
anyhow::ensure!(!component.contains(':'), "contains colon");
306+
anyhow::ensure!(component != "..", "parent directory traversal");
307+
anyhow::ensure!(component != ".", "current directory reference");
308+
anyhow::ensure!(!component.contains('\0'), "contains null byte");
306309
Ok(())
307310
}
308311

312+
#[cfg(test)]
313+
mod tests {
314+
use super::*;
315+
316+
#[test]
317+
fn validate_rejects_empty() {
318+
assert!(validate_path_component("").is_err());
319+
}
320+
321+
#[test]
322+
fn validate_rejects_slash() {
323+
assert!(validate_path_component("a/b").is_err());
324+
}
325+
326+
#[test]
327+
fn validate_rejects_backslash() {
328+
assert!(validate_path_component("a\\b").is_err());
329+
}
330+
331+
#[test]
332+
fn validate_rejects_parent_traversal() {
333+
assert!(validate_path_component("..").is_err());
334+
}
335+
336+
#[test]
337+
fn validate_rejects_dot() {
338+
assert!(validate_path_component(".").is_err());
339+
}
340+
341+
#[test]
342+
fn validate_rejects_null_byte() {
343+
assert!(validate_path_component("a\0b").is_err());
344+
}
345+
346+
#[test]
347+
fn validate_rejects_colon() {
348+
assert!(validate_path_component("C:foo").is_err());
349+
}
350+
351+
#[test]
352+
fn validate_accepts_normal() {
353+
assert!(validate_path_component("file.txt").is_ok());
354+
assert!(validate_path_component("my-file_v2.tar.gz").is_ok());
355+
}
356+
357+
#[test]
358+
fn get_export_path_blocks_drive_prefix() {
359+
let root = Path::new("/tmp/test");
360+
assert!(get_export_path(root, "C:foo").is_err());
361+
}
362+
363+
#[test]
364+
fn get_export_path_blocks_traversal() {
365+
let root = Path::new("/tmp/test");
366+
assert!(get_export_path(root, "../etc/passwd").is_err());
367+
assert!(get_export_path(root, "subdir/../../etc/passwd").is_err());
368+
}
369+
370+
#[test]
371+
fn get_export_path_blocks_backslash() {
372+
assert!(get_export_path(Path::new("/tmp/test"), "file\\name").is_err());
373+
}
374+
375+
#[test]
376+
fn get_export_path_allows_normal() {
377+
let p = get_export_path(Path::new("/tmp/test"), "subdir/file.txt").unwrap();
378+
assert_eq!(p, PathBuf::from("/tmp/test/subdir/file.txt"));
379+
}
380+
}
381+
309382
fn show_get_error(e: GetError) -> GetError {
310383
match &e {
311384
GetError::InitialNext { source, .. } => {

sendme/src/core/send.rs

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -196,18 +196,36 @@ async fn import(path: PathBuf, db: &Store) -> anyhow::Result<(TempTag, u64, Coll
196196
let root = path.parent().context("context get parent")?;
197197
let files = WalkDir::new(path.clone()).into_iter();
198198
let data_sources: Vec<(String, PathBuf)> = files
199-
.map(|entry| {
200-
let entry = entry?;
199+
.filter_map(|entry| {
200+
let entry = match entry {
201+
Ok(e) => e,
202+
Err(e) => {
203+
tracing::warn!("skipping inaccessible entry: {}", e);
204+
return None;
205+
}
206+
};
201207
if !entry.file_type().is_file() {
202-
return Ok(None);
208+
return None;
203209
}
204210
let path = entry.into_path();
205-
let relative = path.strip_prefix(root)?;
206-
let name = canonicalized_path_to_string(relative, true)?;
207-
anyhow::Ok(Some((name, path)))
211+
let relative = match path.strip_prefix(root) {
212+
Ok(r) => r,
213+
Err(e) => {
214+
tracing::warn!("skipping {}: {}", path.display(), e);
215+
return None;
216+
}
217+
};
218+
match canonicalized_path_to_string(relative, true) {
219+
Ok(name) => Some((name, path)),
220+
Err(e) => {
221+
tracing::warn!("skipping {}: {}", path.display(), e);
222+
None
223+
}
224+
}
208225
})
209-
.filter_map(Result::transpose)
210-
.collect::<anyhow::Result<Vec<_>>>()?;
226+
.collect();
227+
228+
anyhow::ensure!(!data_sources.is_empty(), "no valid files to share");
211229

212230
let mut names_and_tags = n0_future::stream::iter(data_sources)
213231
.map(|(name, path)| {
@@ -298,6 +316,65 @@ pub fn canonicalized_path_to_string(
298316
Ok(path_str)
299317
}
300318

319+
#[cfg(test)]
320+
mod tests {
321+
use super::*;
322+
use std::path::Path;
323+
324+
#[cfg(unix)]
325+
#[test]
326+
fn canonicalized_path_rejects_backslash() {
327+
let path = Path::new("system-systemd\\x2dcryptsetup.slice");
328+
assert!(canonicalized_path_to_string(path, true).is_err());
329+
}
330+
331+
#[test]
332+
fn canonicalized_path_accepts_normal() {
333+
let result = canonicalized_path_to_string(Path::new("subdir/file.txt"), true);
334+
assert_eq!(result.unwrap(), "subdir/file.txt");
335+
}
336+
337+
#[test]
338+
fn canonicalized_path_rejects_parent_traversal() {
339+
assert!(canonicalized_path_to_string(Path::new("../etc/passwd"), true).is_err());
340+
}
341+
342+
#[test]
343+
fn canonicalized_path_rejects_absolute_when_relative() {
344+
assert!(canonicalized_path_to_string(Path::new("/etc/passwd"), true).is_err());
345+
}
346+
347+
#[cfg(unix)]
348+
#[tokio::test]
349+
async fn import_skips_invalid_files() {
350+
use tempfile::TempDir;
351+
352+
let td = TempDir::new().unwrap();
353+
let dir = td.path().join("testdir");
354+
std::fs::create_dir_all(&dir).unwrap();
355+
std::fs::write(dir.join("good.txt"), "hello").unwrap();
356+
std::fs::write(dir.join(format!("bad{}file.txt", '\\')), "bad").unwrap();
357+
358+
let path = dir.canonicalize().unwrap();
359+
let root = path.parent().unwrap();
360+
let data_sources: Vec<(String, PathBuf)> = WalkDir::new(path.clone())
361+
.into_iter()
362+
.filter_map(|entry| {
363+
let entry = entry.ok()?;
364+
if !entry.file_type().is_file() {
365+
return None;
366+
}
367+
let path = entry.into_path();
368+
let relative = path.strip_prefix(root).ok()?;
369+
canonicalized_path_to_string(relative, true).ok().map(|name| (name, path))
370+
})
371+
.collect();
372+
373+
assert_eq!(data_sources.len(), 1, "should skip file with backslash");
374+
assert!(data_sources[0].0.contains("good.txt"));
375+
}
376+
}
377+
301378
async fn show_provide_progress_with_logging(
302379
mut recv: mpsc::Receiver<iroh_blobs::provider::events::ProviderMessage>,
303380
app_handle: AppHandle,

sendme/src/main_reference.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,10 +295,13 @@ fn get_or_create_secret(print: bool) -> anyhow::Result<SecretKey> {
295295
}
296296

297297
fn validate_path_component(component: &str) -> anyhow::Result<()> {
298-
anyhow::ensure!(
299-
!component.contains('/'),
300-
"path components must not contain the only correct path separator, /"
301-
);
298+
anyhow::ensure!(!component.is_empty(), "empty path component");
299+
anyhow::ensure!(!component.contains('/'), "contains /");
300+
anyhow::ensure!(!component.contains('\\'), "contains \\");
301+
anyhow::ensure!(!component.contains(':'), "contains colon");
302+
anyhow::ensure!(component != "..", "parent directory traversal");
303+
anyhow::ensure!(component != ".", "current directory reference");
304+
anyhow::ensure!(!component.contains('\0'), "contains null byte");
302305
Ok(())
303306
}
304307

src-tauri/capabilities/default.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@
1414
"os:default",
1515
"opener:default",
1616
"dialog:default",
17-
"shell:default",
1817
"shell:allow-spawn",
19-
"shell:allow-open",
2018
"updater:default"
2119
]
2220
}

src-tauri/capabilities/sendme.json

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,14 @@
1414
"dialog:default",
1515
"dialog:allow-open",
1616
"dialog:allow-save",
17-
"shell:default",
1817
"shell:allow-spawn",
19-
"shell:allow-open",
2018
{
2119
"identifier": "http:default",
2220
"allow": [
2321
{
24-
"url": "https://*:*"
25-
},
26-
{
27-
"url": "http://*:*"
22+
"url": "https://github.com/**"
2823
}
29-
],
30-
"deny": []
24+
]
3125
}
3226
]
3327
}

0 commit comments

Comments
 (0)