1
+ use std:: ffi:: { CStr , CString } ;
1
2
use std:: fs:: { DirBuilder , File , Metadata , OpenOptions } ;
2
3
use std:: io:: { self , Error , ErrorKind } ;
3
- use std:: os:: unix:: fs:: { DirBuilderExt , MetadataExt , PermissionsExt } ;
4
- use std:: os:: unix:: prelude:: OpenOptionsExt ;
5
- use std:: path:: Path ;
4
+ use std:: os:: fd:: { AsFd , AsRawFd , BorrowedFd , FromRawFd , OwnedFd } ;
5
+ use std:: os:: unix:: {
6
+ ffi:: OsStrExt ,
7
+ fs:: { DirBuilderExt , MetadataExt , PermissionsExt } ,
8
+ prelude:: OpenOptionsExt ,
9
+ } ;
10
+ use std:: path:: { Component , Path } ;
11
+
12
+ use super :: { cerr, User } ;
6
13
7
14
// of course we can also write "file & 0o040 != 0", but this makes the intent explicit
8
15
enum Op {
@@ -20,19 +27,23 @@ fn mode(who: Category, what: Op) -> u32 {
20
27
( what as u32 ) << ( 3 * who as u32 )
21
28
}
22
29
30
+ /// Open sudo configuration using various security checks
23
31
pub fn secure_open ( path : impl AsRef < Path > , check_parent_dir : bool ) -> io:: Result < File > {
24
32
let mut open_options = OpenOptions :: new ( ) ;
25
33
open_options. read ( true ) ;
34
+
26
35
secure_open_impl ( path. as_ref ( ) , & mut open_options, check_parent_dir, false )
27
36
}
28
37
38
+ /// Open a timestamp cookie file using various security checks
29
39
pub fn secure_open_cookie_file ( path : impl AsRef < Path > ) -> io:: Result < File > {
30
40
let mut open_options = OpenOptions :: new ( ) ;
31
41
open_options
32
42
. read ( true )
33
43
. write ( true )
34
44
. create ( true )
35
45
. mode ( mode ( Category :: Owner , Op :: Write ) | mode ( Category :: Owner , Op :: Read ) ) ;
46
+
36
47
secure_open_impl ( path. as_ref ( ) , & mut open_options, true , true )
37
48
}
38
49
@@ -103,6 +114,93 @@ fn secure_open_impl(
103
114
Ok ( file)
104
115
}
105
116
117
+ #[ cfg_attr( not( feature = "sudoedit" ) , allow( dead_code) ) ]
118
+ fn open_at ( parent : BorrowedFd , file_name : & CStr , create : bool ) -> io:: Result < OwnedFd > {
119
+ let flags = if create {
120
+ libc:: O_NOFOLLOW | libc:: O_RDWR | libc:: O_CREAT
121
+ } else {
122
+ libc:: O_NOFOLLOW | libc:: O_RDONLY
123
+ } ;
124
+
125
+ // the mode for files that are created is hardcoded, as it is in ogsudo
126
+ let mode = libc:: S_IRUSR | libc:: S_IWUSR | libc:: S_IRGRP | libc:: S_IROTH ;
127
+
128
+ // SAFETY: by design, a correct CStr pointer is passed to openat; only if this call succeeds
129
+ // is the file descriptor it returns (which is then necessarily valid) passed to from_raw_fd
130
+ unsafe {
131
+ let fd = cerr ( libc:: openat (
132
+ parent. as_raw_fd ( ) ,
133
+ file_name. as_ptr ( ) ,
134
+ flags,
135
+ mode,
136
+ ) ) ?;
137
+
138
+ Ok ( OwnedFd :: from_raw_fd ( fd) )
139
+ }
140
+ }
141
+
142
+ /// This opens a file making sure that
143
+ /// - no directory leading up to the file is editable by the user
144
+ /// - no components are a symbolic link
145
+ #[ cfg_attr( not( feature = "sudoedit" ) , allow( dead_code) ) ]
146
+ fn traversed_secure_open ( path : impl AsRef < Path > , user : & User ) -> io:: Result < File > {
147
+ let path = path. as_ref ( ) ;
148
+
149
+ let Some ( file_name) = path. file_name ( ) else {
150
+ return Err ( io:: Error :: new ( ErrorKind :: InvalidInput , "invalid path" ) ) ;
151
+ } ;
152
+
153
+ let mut components = path. parent ( ) . unwrap_or ( Path :: new ( "" ) ) . components ( ) ;
154
+ if components. next ( ) != Some ( Component :: RootDir ) {
155
+ return Err ( io:: Error :: new (
156
+ ErrorKind :: InvalidInput ,
157
+ "path must be absolute" ,
158
+ ) ) ;
159
+ }
160
+
161
+ let user_cannot_write = |file : & File | -> io:: Result < ( ) > {
162
+ let meta = file. metadata ( ) ?;
163
+ let perms = meta. permissions ( ) . mode ( ) ;
164
+
165
+ if perms & mode ( Category :: World , Op :: Write ) != 0
166
+ || ( perms & mode ( Category :: Group , Op :: Write ) != 0 ) && user. gid . inner ( ) == meta. gid ( )
167
+ || ( perms & mode ( Category :: Owner , Op :: Write ) != 0 ) && user. uid . inner ( ) == meta. uid ( )
168
+ {
169
+ Err ( io:: Error :: new (
170
+ ErrorKind :: PermissionDenied ,
171
+ "cannot open a file in a path writeable by the user" ,
172
+ ) )
173
+ } else {
174
+ Ok ( ( ) )
175
+ }
176
+ } ;
177
+
178
+ let mut cur = File :: open ( "/" ) ?;
179
+ user_cannot_write ( & cur) ?;
180
+
181
+ for component in components {
182
+ let dir: CString = match component {
183
+ Component :: Normal ( dir) => CString :: new ( dir. as_bytes ( ) ) ?,
184
+ Component :: CurDir => cstr ! ( "." ) . to_owned ( ) ,
185
+ Component :: ParentDir => cstr ! ( ".." ) . to_owned ( ) ,
186
+ _ => {
187
+ return Err ( io:: Error :: new (
188
+ ErrorKind :: InvalidInput ,
189
+ "error in provided path" ,
190
+ ) )
191
+ }
192
+ } ;
193
+
194
+ cur = open_at ( cur. as_fd ( ) , & dir, false ) ?. into ( ) ;
195
+ user_cannot_write ( & cur) ?;
196
+ }
197
+
198
+ cur = open_at ( cur. as_fd ( ) , & CString :: new ( file_name. as_bytes ( ) ) ?, true ) ?. into ( ) ;
199
+ user_cannot_write ( & cur) ?;
200
+
201
+ Ok ( cur)
202
+ }
203
+
106
204
#[ cfg( test) ]
107
205
mod test {
108
206
use super :: * ;
@@ -135,4 +233,50 @@ mod test {
135
233
fn test_secure_open_cookie_file ( ) {
136
234
assert ! ( secure_open_cookie_file( "/etc/hosts" ) . is_err( ) ) ;
137
235
}
236
+
237
+ #[ test]
238
+ fn test_traverse_secure_open_negative ( ) {
239
+ use crate :: common:: resolve:: CurrentUser ;
240
+
241
+ let root = User :: from_name ( cstr ! ( "root" ) ) . unwrap ( ) . unwrap ( ) ;
242
+ let user = CurrentUser :: resolve ( ) . unwrap ( ) ;
243
+
244
+ // not allowed -- invalid
245
+ assert ! ( traversed_secure_open( "/" , & root) . is_err( ) ) ;
246
+ // not allowed since the path is not absolute
247
+ assert ! ( traversed_secure_open( "./hello.txt" , & root) . is_err( ) ) ;
248
+ // not allowed since root can write to "/"
249
+ assert ! ( traversed_secure_open( "/hello.txt" , & root) . is_err( ) ) ;
250
+ // not allowed since "/tmp" is a directory
251
+ assert ! ( traversed_secure_open( "/tmp" , & user) . is_err( ) ) ;
252
+ // not allowed since anybody can write to "/tmp"
253
+ assert ! ( traversed_secure_open( "/tmp/foo/hello.txt" , & user) . is_err( ) ) ;
254
+ // not allowed since "/bin" is a symlink
255
+ assert ! ( traversed_secure_open( "/bin/hello.txt" , & user) . is_err( ) ) ;
256
+ }
257
+
258
+ #[ test]
259
+ fn test_traverse_secure_open_positive ( ) {
260
+ use crate :: common:: resolve:: CurrentUser ;
261
+ use crate :: system:: { GroupId , UserId } ;
262
+
263
+ let other_user = CurrentUser :: fake ( User {
264
+ uid : UserId :: new ( 1042 ) ,
265
+ gid : GroupId :: new ( 1042 ) ,
266
+
267
+ name : "test" . into ( ) ,
268
+ home : "/home/test" . into ( ) ,
269
+ shell : "/bin/sh" . into ( ) ,
270
+ groups : vec ! [ ] ,
271
+ } ) ;
272
+
273
+ // allowed!
274
+ let path = std:: env:: current_dir ( )
275
+ . unwrap ( )
276
+ . join ( "sudo-rs-test-file.txt" ) ;
277
+ let file = traversed_secure_open ( & path, & other_user) . unwrap ( ) ;
278
+ if file. metadata ( ) . is_ok_and ( |meta| meta. len ( ) == 0 ) {
279
+ std:: fs:: remove_file ( path) . unwrap ( ) ;
280
+ }
281
+ }
138
282
}
0 commit comments