Skip to content

Commit ea45f47

Browse files
authored
feat: re-seed from system randomness on collision (#314)
Re-seed thread-local RNG from system randomness if we run into a temporary file-name collision. This should address the concerns about using a predictable RNG without hurting performance in the common case where nobody is trying to predict our filenames. I'm only re-seeding once because if we _still_ fail to create a temporary file, the collision was likely due to too many temporary files instead of an attacker predicting our random temporary file names. I've also reduced the number of tries from 2^31 to 2^16. If it takes more than that to create a temporary file, something else is wrong. Pausing for a long time is usually worse than just failing. fixes #178
1 parent 16209da commit ea45f47

File tree

5 files changed

+90
-44
lines changed

5 files changed

+90
-44
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 3.15.0
4+
5+
Re-seed the per-thread RNG from system randomness when we repeatedly fail to create temporary files (#314). This resolves a potential DoS vector (#178) while avoiding `getrandom` in the common case where it's necessary. The feature is optional but enabled by default via the `getrandom` feature.
6+
7+
For libc-free builds, you'll either need to disable this feature or opt-in to a different [`getrandom` backend](https://github.com/rust-random/getrandom?tab=readme-ov-file#opt-in-backends).
8+
39
## 3.14.0
410

511
- Make the wasip2 target work (requires tempfile's "nightly" feature to be enabled). [#305](https://github.com/Stebalien/tempfile/pull/305).

Cargo.toml

+4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ fastrand = "2.1.1"
2222
# Not available in stdlib until 1.70, but we support 1.63 to support Debian stable.
2323
once_cell = { version = "1.19.0", default-features = false, features = ["std"] }
2424

25+
[target.'cfg(any(unix, windows, target_os = "wasi"))'.dependencies]
26+
getrandom = { version = "0.2.15", default-features = false, optional = true }
27+
2528
[target.'cfg(any(unix, target_os = "wasi"))'.dependencies]
2629
rustix = { version = "0.38.39", features = ["fs"] }
2730

@@ -36,4 +39,5 @@ features = [
3639
doc-comment = "0.3"
3740

3841
[features]
42+
default = ["getrandom"]
3943
nightly = []

src/lib.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
//! `tempfile` doesn't rely on file paths so this isn't an issue. However, `NamedTempFile` does
3030
//! rely on file paths for _some_ operations. See the security documentation on
3131
//! the `NamedTempFile` type for more information.
32-
//!
32+
//!
3333
//! The OWASP Foundation provides a resource on vulnerabilities concerning insecure
3434
//! temporary files: https://owasp.org/www-community/vulnerabilities/Insecure_Temporary_File
3535
//!
@@ -150,7 +150,7 @@
150150
#[cfg(doctest)]
151151
doc_comment::doctest!("../README.md");
152152

153-
const NUM_RETRIES: u32 = 1 << 31;
153+
const NUM_RETRIES: u32 = 65536;
154154
const NUM_RAND_CHARS: usize = 6;
155155

156156
use std::ffi::OsStr;

src/util.rs

+19-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,25 @@ pub fn create_helper<R>(
3232
1
3333
};
3434

35-
for _ in 0..num_retries {
35+
for i in 0..num_retries {
36+
// If we fail to create the file the first three times, re-seed from system randomness in
37+
// case an attacker is predicting our randomness (fastrand is predictable). If re-seeding
38+
// doesn't help, either:
39+
//
40+
// 1. We have lots of temporary files, possibly created by an attacker but not necessarily.
41+
// Re-seeding the randomness won't help here.
42+
// 2. We're failing to create random files for some other reason. This shouldn't be the case
43+
// given that we're checking error kinds, but it could happen.
44+
#[cfg(all(
45+
feature = "getrandom",
46+
any(windows, unix, target_os = "redox", target_os = "wasi")
47+
))]
48+
if i == 3 {
49+
let mut seed = [0u8; 8];
50+
if getrandom::getrandom(&mut seed).is_ok() {
51+
fastrand::seed(u64::from_ne_bytes(seed));
52+
}
53+
}
3654
let path = base.join(tmpname(prefix, suffix, random_len));
3755
return match f(path) {
3856
Err(ref e) if e.kind() == io::ErrorKind::AlreadyExists && num_retries > 1 => continue,

tests/namedtempfile.rs

+59-41
Original file line numberDiff line numberDiff line change
@@ -421,55 +421,73 @@ fn test_make_uds() {
421421
#[cfg(unix)]
422422
#[test]
423423
fn test_make_uds_conflict() {
424+
use std::io::ErrorKind;
424425
use std::os::unix::net::UnixListener;
425-
use std::sync::atomic::{AtomicUsize, Ordering};
426-
use std::sync::Arc;
427-
428-
// Check that retries happen correctly by racing N different threads.
429-
430-
const NTHREADS: usize = 20;
431-
432-
// The number of times our callback was called.
433-
let tries = Arc::new(AtomicUsize::new(0));
434-
435-
let mut threads = Vec::with_capacity(NTHREADS);
436-
437-
for _ in 0..NTHREADS {
438-
let tries = tries.clone();
439-
threads.push(std::thread::spawn(move || {
440-
// Ensure that every thread uses the same seed so we are guaranteed
441-
// to retry. Note that fastrand seeds are thread-local.
442-
fastrand::seed(42);
443-
444-
Builder::new()
445-
.prefix("tmp")
446-
.suffix(".sock")
447-
.rand_bytes(12)
448-
.make(|path| {
449-
tries.fetch_add(1, Ordering::Relaxed);
450-
UnixListener::bind(path)
451-
})
452-
}));
453-
}
454426

455-
// Join all threads, but don't drop the temp file yet. Otherwise, we won't
456-
// get a deterministic number of `tries`.
457-
let sockets: Vec<_> = threads
458-
.into_iter()
459-
.map(|thread| thread.join().unwrap().unwrap())
460-
.collect();
461-
462-
// Number of tries is exactly equal to (n*(n+1))/2.
463-
assert_eq!(
464-
tries.load(Ordering::Relaxed),
465-
(NTHREADS * (NTHREADS + 1)) / 2
466-
);
427+
let sockets = std::iter::repeat_with(|| {
428+
Builder::new()
429+
.prefix("tmp")
430+
.suffix(".sock")
431+
.rand_bytes(1)
432+
.make(|path| UnixListener::bind(path))
433+
})
434+
.take_while(|r| match r {
435+
Ok(_) => true,
436+
Err(e) if matches!(e.kind(), ErrorKind::AddrInUse | ErrorKind::AlreadyExists) => false,
437+
Err(e) => panic!("unexpected error {e}"),
438+
})
439+
.collect::<Result<Vec<_>, _>>()
440+
.unwrap();
441+
442+
// Number of sockets we can create. Depends on whether or not the filesystem is case sensitive.
443+
444+
#[cfg(target_os = "macos")]
445+
const NUM_FILES: usize = 36;
446+
#[cfg(not(target_os = "macos"))]
447+
const NUM_FILES: usize = 62;
448+
449+
assert_eq!(sockets.len(), NUM_FILES);
467450

468451
for socket in sockets {
469452
assert!(socket.path().exists());
470453
}
471454
}
472455

456+
/// Make sure we re-seed with system randomness if we run into a conflict.
457+
#[test]
458+
fn test_reseed() {
459+
// Deterministic seed.
460+
fastrand::seed(42);
461+
462+
let mut attempts = 0;
463+
let _files: Vec<_> = std::iter::repeat_with(|| {
464+
Builder::new()
465+
.make(|path| {
466+
attempts += 1;
467+
File::options().write(true).create_new(true).open(path)
468+
})
469+
.unwrap()
470+
})
471+
.take(5)
472+
.collect();
473+
474+
assert_eq!(5, attempts);
475+
attempts = 0;
476+
477+
// Re-seed to cause a conflict.
478+
fastrand::seed(42);
479+
480+
let _f = Builder::new()
481+
.make(|path| {
482+
attempts += 1;
483+
File::options().write(true).create_new(true).open(path)
484+
})
485+
.unwrap();
486+
487+
// We expect exactly three conflict before we re-seed with system randomness.
488+
assert_eq!(4, attempts);
489+
}
490+
473491
// Issue #224.
474492
#[test]
475493
fn test_overly_generic_bounds() {

0 commit comments

Comments
 (0)