Skip to content

Commit 8d55b2a

Browse files
committed
mv,cp: fix xattr TOCTOU by using file descriptor-based operations
Closes: #10014
1 parent 7609966 commit 8d55b2a

8 files changed

Lines changed: 435 additions & 45 deletions

File tree

src/uu/cp/src/cp.rs

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::os::unix::net::UnixListener;
1616
use std::path::{Path, PathBuf, StripPrefixError};
1717
use std::{fmt, io};
1818
#[cfg(all(unix, not(target_os = "android")))]
19-
use uucore::fsxattr::{copy_xattrs, copy_xattrs_skip_selinux};
19+
use uucore::fsxattr::{copy_xattrs_fd, copy_xattrs_skip_selinux};
2020
use uucore::translate;
2121

2222
use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, value_parser};
@@ -1715,6 +1715,8 @@ pub(crate) fn set_selinux_context(path: &Path, context: Option<&String>) -> Copy
17151715
/// or if xattr copying fails.
17161716
#[cfg(all(unix, not(target_os = "android")))]
17171717
fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyResult<()> {
1718+
use std::fs::File;
1719+
use uucore::fsxattr::copy_xattrs;
17181720
let metadata = fs::symlink_metadata(dest)?;
17191721

17201722
// Check if the destination file is currently read-only for the user.
@@ -1734,6 +1736,13 @@ fn copy_extended_attrs(source: &Path, dest: &Path, skip_selinux: bool) -> CopyRe
17341736
// When -Z is used, skip copying security.selinux xattr so that
17351737
// the default context can be set instead of preserving from source
17361738
copy_xattrs_skip_selinux(source, dest)
1739+
} else if metadata.is_file() {
1740+
// Use file descriptor-based operations for regular files to avoid TOCTOU races.
1741+
// Directories cannot be opened with write mode for xattr operations
1742+
// Symlinks (especially dangling ones) cannot be opened via File::open
1743+
let source_file = File::open(source)?;
1744+
let dest_file = OpenOptions::new().write(true).open(dest)?;
1745+
copy_xattrs_fd(&source_file, &dest_file)
17371746
} else {
17381747
copy_xattrs(source, dest)
17391748
};
@@ -2591,7 +2600,40 @@ fn copy_file(
25912600
fs::set_permissions(dest, dest_permissions).ok();
25922601
}
25932602

2594-
let copy_attributes_result = if options.dereference(source_in_command_line) {
2603+
let copy_attributes_result = if source_is_stream && options.copy_contents {
2604+
// When copying contents from a stream (like a FIFO with --copy-contents),
2605+
// we need to preserve ownership but can't re-access the source as it may block.
2606+
// Use the metadata we already have.
2607+
#[cfg(unix)]
2608+
if let Preserve::Yes { .. } = options.attributes.ownership {
2609+
use std::os::unix::prelude::MetadataExt;
2610+
use uucore::perms::{Verbosity, VerbosityLevel, wrap_chown};
2611+
2612+
let dest_uid = source_metadata.uid();
2613+
let dest_gid = source_metadata.gid();
2614+
if let Ok(dest_meta) = dest.symlink_metadata() {
2615+
let try_chown = |uid| {
2616+
wrap_chown(
2617+
dest,
2618+
&dest_meta,
2619+
uid,
2620+
Some(dest_gid),
2621+
false,
2622+
Verbosity {
2623+
groups_only: false,
2624+
level: VerbosityLevel::Silent,
2625+
},
2626+
)
2627+
};
2628+
// gnu compatibility: cp doesn't report an error if it fails to set the ownership,
2629+
// and will fall back to changing only the gid if possible.
2630+
if try_chown(Some(dest_uid)).is_err() {
2631+
let _ = try_chown(None);
2632+
}
2633+
}
2634+
}
2635+
Ok(())
2636+
} else if options.dereference(source_in_command_line) {
25952637
// Try to canonicalize, but if it fails (e.g., due to inaccessible parent directories),
25962638
// fall back to the original source path
25972639
let src_for_attrs = canonicalize(source, MissingHandling::Normal, ResolveMode::Physical)
@@ -2605,12 +2647,6 @@ fn copy_file(
26052647
false,
26062648
options.set_selinux_context,
26072649
)
2608-
} else if source_is_stream && !source.exists() {
2609-
// Some stream files may not exist after we have copied it,
2610-
// like anonymous pipes. Thus, we can't really copy its
2611-
// attributes. However, this is already handled in the stream
2612-
// copy function (see `copy_stream` under platform/linux.rs).
2613-
Ok(())
26142650
} else {
26152651
copy_attributes(
26162652
source,

src/uu/cp/src/platform/linux.rs

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66

77
use libc::{SEEK_DATA, SEEK_HOLE};
88
use std::fs::{File, OpenOptions};
9-
use std::io::Read;
9+
use std::io::{self, Read};
1010
use std::os::unix::fs::FileExt;
1111
use std::os::unix::fs::MetadataExt;
1212
use std::os::unix::fs::{FileTypeExt, OpenOptionsExt};
1313
use std::os::unix::io::AsRawFd;
1414
use std::path::Path;
1515
use uucore::buf_copy;
16-
use uucore::mode::get_umask;
1716
use uucore::translate;
1817

1918
use crate::{
@@ -50,6 +49,25 @@ enum CopyMethod {
5049
SparseCopyWithoutHole,
5150
}
5251

52+
/// Copy file contents with restrictive permissions initially to prevent race conditions.
53+
/// Creates the destination file with mode 0600 & !umask, copies the content,
54+
/// and lets the caller set the final permissions.
55+
fn copy_file_with_secure_permissions<P>(source: P, dest: P) -> io::Result<u64>
56+
where
57+
P: AsRef<Path>,
58+
{
59+
let mut src_file = File::open(&source)?;
60+
// Create destination with restrictive permissions initially (to prevent race conditions)
61+
// Mode 0o600 means read/write for owner only
62+
let mut dst_file = OpenOptions::new()
63+
.create(true)
64+
.write(true)
65+
.truncate(true)
66+
.mode(0o600)
67+
.open(&dest)?;
68+
io::copy(&mut src_file, &mut dst_file)
69+
}
70+
5371
/// Use the Linux `ioctl_ficlone` API to do a copy-on-write clone.
5472
///
5573
/// `fallback` controls what to do if the system call fails.
@@ -59,7 +77,13 @@ where
5977
P: AsRef<Path>,
6078
{
6179
let src_file = File::open(&source)?;
62-
let dst_file = File::create(&dest)?;
80+
// Create destination with restrictive permissions initially (to prevent race conditions)
81+
// Mode 0o600 means read/write for owner only
82+
let dst_file = OpenOptions::new()
83+
.create(true)
84+
.write(true)
85+
.mode(0o600)
86+
.open(&dest)?;
6387
let src_fd = src_file.as_raw_fd();
6488
let dst_fd = dst_file.as_raw_fd();
6589
let result = unsafe { libc::ioctl(dst_fd, libc::FICLONE, src_fd) };
@@ -68,7 +92,7 @@ where
6892
}
6993
match fallback {
7094
CloneFallback::Error => Err(std::io::Error::last_os_error()),
71-
CloneFallback::FSCopy => std::fs::copy(source, dest).map(|_| ()),
95+
CloneFallback::FSCopy => copy_file_with_secure_permissions(source, dest).map(|_| ()),
7296
CloneFallback::SparseCopy => sparse_copy(source, dest),
7397
CloneFallback::SparseCopyWithoutHole => sparse_copy_without_hole(source, dest),
7498
}
@@ -125,7 +149,13 @@ where
125149
P: AsRef<Path>,
126150
{
127151
let src_file = File::open(source)?;
128-
let dst_file = File::create(dest)?;
152+
// Create destination with restrictive permissions initially (to prevent race conditions)
153+
// Mode 0o600 means read/write for owner only
154+
let dst_file = OpenOptions::new()
155+
.create(true)
156+
.write(true)
157+
.mode(0o600)
158+
.open(dest)?;
129159
let dst_fd = dst_file.as_raw_fd();
130160

131161
let size = src_file.metadata()?.size();
@@ -175,7 +205,13 @@ where
175205
P: AsRef<Path>,
176206
{
177207
let mut src_file = File::open(source)?;
178-
let dst_file = File::create(dest)?;
208+
// Create destination with restrictive permissions initially (to prevent race conditions)
209+
// Mode 0o600 means read/write for owner only
210+
let dst_file = OpenOptions::new()
211+
.create(true)
212+
.write(true)
213+
.mode(0o600)
214+
.open(dest)?;
179215
let dst_fd = dst_file.as_raw_fd();
180216

181217
let size: usize = src_file.metadata()?.size().try_into().unwrap();
@@ -237,18 +273,18 @@ where
237273
// TODO Update the code below to respect the case where
238274
// `--preserve=ownership` is not true.
239275
let mut src_file = File::open(&source)?;
240-
let mode = 0o622 & !get_umask();
276+
// Create with restrictive permissions initially to prevent race conditions
277+
// Mode 0o600 means read/write for owner only
278+
// Use truncate(true) to ensure we overwrite existing content
241279
let mut dst_file = OpenOptions::new()
242280
.create(true)
243281
.write(true)
244-
.mode(mode)
282+
.truncate(true)
283+
.mode(0o600)
245284
.open(&dest)?;
246285

247286
let dest_is_stream = is_stream(&dst_file.metadata()?);
248-
if !dest_is_stream {
249-
// `copy_stream` doesn't clear the dest file, if dest is not a stream, we should clear it manually.
250-
dst_file.set_len(0)?;
251-
}
287+
// No need to set_len(0) since we're already using truncate(true)
252288

253289
let num_bytes_copied = buf_copy::copy_stream(&mut src_file, &mut dst_file)
254290
.map_err(|e| std::io::Error::other(format!("{e}")))?;
@@ -287,7 +323,9 @@ pub(crate) fn copy_on_write(
287323
}
288324

289325
match copy_method {
290-
CopyMethod::FSCopy => std::fs::copy(source, dest).map(|_| ()),
326+
CopyMethod::FSCopy => {
327+
copy_file_with_secure_permissions(source, dest).map(|_| ())
328+
}
291329
_ => sparse_copy(source, dest),
292330
}
293331
}
@@ -303,7 +341,7 @@ pub(crate) fn copy_on_write(
303341
if let Ok(debug) = result {
304342
copy_debug = debug;
305343
}
306-
std::fs::copy(source, dest).map(|_| ())
344+
copy_file_with_secure_permissions(source, dest).map(|_| ())
307345
}
308346
}
309347
(ReflinkMode::Never, SparseMode::Auto) => {
@@ -322,7 +360,7 @@ pub(crate) fn copy_on_write(
322360

323361
match copy_method {
324362
CopyMethod::SparseCopyWithoutHole => sparse_copy_without_hole(source, dest),
325-
_ => std::fs::copy(source, dest).map(|_| ()),
363+
_ => copy_file_with_secure_permissions(source, dest).map(|_| ()),
326364
}
327365
}
328366
}

src/uu/cp/src/platform/macos.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// spell-checker:ignore reflink
66
use std::ffi::CString;
77
use std::fs::{self, File, OpenOptions};
8+
use std::io;
89
use std::os::unix::ffi::OsStrExt;
910
use std::os::unix::fs::OpenOptionsExt;
1011
use std::path::Path;
@@ -13,13 +14,30 @@ use uucore::buf_copy;
1314
use uucore::display::Quotable;
1415
use uucore::translate;
1516

16-
use uucore::mode::get_umask;
17-
1817
use crate::{
1918
CopyDebug, CopyResult, CpError, OffloadReflinkDebug, ReflinkMode, SparseDebug, SparseMode,
2019
is_stream,
2120
};
2221

22+
/// Copy file contents with restrictive permissions initially to prevent race conditions.
23+
/// Creates the destination file with mode 0600 & !umask, copies the content,
24+
/// and lets the caller set the final permissions.
25+
fn copy_file_with_secure_permissions<P>(source: P, dest: P) -> io::Result<u64>
26+
where
27+
P: AsRef<Path>,
28+
{
29+
let mut src_file = File::open(&source)?;
30+
// Create destination with restrictive permissions initially (to prevent race conditions)
31+
// Mode 0o600 means read/write for owner only
32+
let mut dst_file = OpenOptions::new()
33+
.create(true)
34+
.write(true)
35+
.truncate(true)
36+
.mode(0o600)
37+
.open(&dest)?;
38+
io::copy(&mut src_file, &mut dst_file)
39+
}
40+
2341
/// Copies `source` to `dest` using copy-on-write if possible.
2442
pub(crate) fn copy_on_write(
2543
source: &Path,
@@ -92,24 +110,24 @@ pub(crate) fn copy_on_write(
92110
copy_debug.reflink = OffloadReflinkDebug::Yes;
93111
if source_is_stream {
94112
let mut src_file = File::open(source)?;
95-
let mode = 0o622 & !get_umask();
113+
// Create with restrictive permissions initially to prevent race conditions
114+
// Mode 0o600 means read/write for owner only
96115
let mut dst_file = OpenOptions::new()
97116
.create(true)
98117
.write(true)
99-
.mode(mode)
118+
.truncate(true)
119+
.mode(0o600)
100120
.open(dest)?;
101121

102122
let dest_is_stream = is_stream(&dst_file.metadata()?);
103-
if !dest_is_stream {
104-
// `copy_stream` doesn't clear the dest file, if dest is not a stream, we should clear it manually.
105-
dst_file.set_len(0)?;
106-
}
123+
// No need to set_len(0) since we're already using truncate(true)
107124

108125
buf_copy::copy_stream(&mut src_file, &mut dst_file)
109126
.map_err(|_| std::io::Error::from(std::io::ErrorKind::Other))
110127
.map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
111128
} else {
112-
fs::copy(source, dest).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
129+
copy_file_with_secure_permissions(source, dest)
130+
.map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
113131
}
114132
}
115133

src/uu/cp/src/platform/other_unix.rs

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,37 @@
44
// file that was distributed with this source code.
55
// spell-checker:ignore reflink
66
use std::fs::{self, File, OpenOptions};
7+
use std::io;
78
use std::os::unix::fs::OpenOptionsExt;
89
use std::path::Path;
910

1011
use uucore::buf_copy;
11-
use uucore::mode::get_umask;
1212
use uucore::translate;
1313

1414
use crate::{
1515
CopyDebug, CopyResult, CpError, OffloadReflinkDebug, ReflinkMode, SparseDebug, SparseMode,
1616
is_stream,
1717
};
1818

19+
/// Copy file contents with restrictive permissions initially to prevent race conditions.
20+
/// Creates the destination file with mode 0600 & !umask, copies the content,
21+
/// and lets the caller set the final permissions.
22+
fn copy_file_with_secure_permissions<P>(source: P, dest: P) -> io::Result<u64>
23+
where
24+
P: AsRef<Path>,
25+
{
26+
let mut src_file = File::open(&source)?;
27+
// Create destination with restrictive permissions initially (to prevent race conditions)
28+
// Mode 0o600 means read/write for owner only
29+
let mut dst_file = OpenOptions::new()
30+
.create(true)
31+
.write(true)
32+
.truncate(true)
33+
.mode(0o600)
34+
.open(&dest)?;
35+
io::copy(&mut src_file, &mut dst_file)
36+
}
37+
1938
/// Copies `source` to `dest` for systems without copy-on-write
2039
pub(crate) fn copy_on_write(
2140
source: &Path,
@@ -43,18 +62,17 @@ pub(crate) fn copy_on_write(
4362

4463
if source_is_stream {
4564
let mut src_file = File::open(source)?;
46-
let mode = 0o622 & !get_umask();
65+
// Create with restrictive permissions initially to prevent race conditions
66+
// Mode 0o600 means read/write for owner only
4767
let mut dst_file = OpenOptions::new()
4868
.create(true)
4969
.write(true)
50-
.mode(mode)
70+
.truncate(true)
71+
.mode(0o600)
5172
.open(dest)?;
5273

5374
let dest_is_stream = is_stream(&dst_file.metadata()?);
54-
if !dest_is_stream {
55-
// `copy_stream` doesn't clear the dest file, if dest is not a stream, we should clear it manually.
56-
dst_file.set_len(0)?;
57-
}
75+
// No need to set_len(0) since we're already using truncate(true)
5876

5977
buf_copy::copy_stream(&mut src_file, &mut dst_file)
6078
.map_err(|_| std::io::Error::from(std::io::ErrorKind::Other))
@@ -63,7 +81,8 @@ pub(crate) fn copy_on_write(
6381
return Ok(copy_debug);
6482
}
6583

66-
fs::copy(source, dest).map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
84+
copy_file_with_secure_permissions(source, dest)
85+
.map_err(|e| CpError::IoErrContext(e, context.to_owned()))?;
6786

6887
Ok(copy_debug)
6988
}

0 commit comments

Comments
 (0)