Skip to content

Commit c8d958a

Browse files
authored
Prevent accidental lockouts (#1045)
2 parents 7965a5c + 1a0576d commit c8d958a

File tree

1 file changed

+103
-38
lines changed

1 file changed

+103
-38
lines changed

src/visudo/mod.rs

Lines changed: 103 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,19 @@ mod cli;
44
mod help;
55

66
use std::{
7+
env, ffi,
78
fs::{File, Permissions},
89
io::{self, Read, Seek, Write},
910
os::unix::prelude::{MetadataExt, PermissionsExt},
1011
path::{Path, PathBuf},
1112
process::Command,
13+
str,
1214
};
1315

1416
use crate::{
1517
common::resolve::CurrentUser,
1618
sudo::{candidate_sudoers_file, diagnostic},
17-
sudoers::Sudoers,
19+
sudoers::{self, Sudoers},
1820
system::{
1921
can_execute,
2022
file::{create_temporary_dir, Chown, FileLock},
@@ -265,7 +267,7 @@ fn edit_sudoers_file(
265267
.spawn()?
266268
.wait_with_output()?;
267269

268-
let (_sudoers, errors) = File::open(tmp_path)
270+
let (sudoers, errors) = File::open(tmp_path)
269271
.and_then(|reader| Sudoers::read(reader, tmp_path))
270272
.map_err(|err| {
271273
io_msg!(
@@ -276,46 +278,42 @@ fn edit_sudoers_file(
276278
)
277279
})?;
278280

279-
if errors.is_empty() {
280-
break;
281-
}
282-
283-
writeln!(stderr, "The provided sudoers file format is not recognized or contains syntax errors. Please review:\n")?;
284-
285-
for crate::sudoers::Error {
286-
message,
287-
source,
288-
location,
289-
} in errors
290-
{
291-
let path = source.as_deref().unwrap_or(sudoers_path);
292-
diagnostic::diagnostic!("syntax error: {message}", path @ location);
293-
}
294-
295-
writeln!(stderr)?;
296-
297-
let stdin = io::stdin();
298-
let stdout = io::stdout();
281+
if !errors.is_empty() {
282+
writeln!(stderr, "The provided sudoers file format is not recognized or contains syntax errors. Please review:\n")?;
283+
284+
for crate::sudoers::Error {
285+
message,
286+
source,
287+
location,
288+
} in errors
289+
{
290+
let path = source.as_deref().unwrap_or(sudoers_path);
291+
diagnostic::diagnostic!("syntax error: {message}", path @ location);
292+
}
299293

300-
let mut stdin_handle = stdin.lock();
301-
let mut stdout_handle = stdout.lock();
294+
writeln!(stderr)?;
302295

303-
loop {
304-
stdout_handle
305-
.write_all("What now? e(x)it without saving / (e)dit again: ".as_bytes())?;
306-
stdout_handle.flush()?;
307-
308-
let mut input = [0u8];
309-
if let Err(err) = stdin_handle.read_exact(&mut input) {
310-
writeln!(stderr, "visudo: cannot read user input: {err}")?;
311-
return Ok(());
296+
match ask_response(b"What now? e(x)it without saving / (e)dit again: ", b"xe")? {
297+
b'x' => return Ok(()),
298+
_ => continue,
312299
}
313-
314-
match &input {
315-
b"e" => break,
316-
b"x" => return Ok(()),
317-
input => writeln!(stderr, "Invalid option: {:?}\n", std::str::from_utf8(input))?,
300+
} else {
301+
if sudo_visudo_is_allowed(sudoers, &host_name) == Some(false) {
302+
writeln!(
303+
stderr,
304+
"It looks like you have removed your ability to run 'sudo visudo' again.\n"
305+
)?;
306+
match ask_response(
307+
b"What now? e(x)it without saving / (e)dit again / lock me out and (S)ave: ",
308+
b"xeS",
309+
)? {
310+
b'x' => return Ok(()),
311+
b'S' => {}
312+
_ => continue,
313+
}
318314
}
315+
316+
break;
319317
}
320318
}
321319

@@ -354,3 +352,70 @@ fn editor_path_fallback() -> io::Result<PathBuf> {
354352
"cannot find text editor",
355353
))
356354
}
355+
356+
// To detect potential lock-outs if the user called "sudo visudo".
357+
// Note that SUDO_USER will normally be set by sudo.
358+
//
359+
// This returns Some(false) if visudo is forbidden under the given config;
360+
// Some(true) if it is allowed; and None if it cannot be determined, which
361+
// will be the case if e.g. visudo was simply run as root.
362+
fn sudo_visudo_is_allowed(mut sudoers: Sudoers, host_name: &Hostname) -> Option<bool> {
363+
let sudo_user =
364+
User::from_name(&ffi::CString::new(env::var("SUDO_USER").ok()?).ok()?).ok()??;
365+
366+
let super_user = User::from_uid(UserId::ROOT).ok()??;
367+
368+
let request = sudoers::Request {
369+
user: &super_user,
370+
group: &super_user.primary_group().ok()?,
371+
command: &env::current_exe().ok()?,
372+
arguments: &[],
373+
};
374+
375+
Some(matches!(
376+
sudoers
377+
.check(&sudo_user, host_name, request)
378+
.authorization(),
379+
sudoers::Authorization::Allowed { .. }
380+
))
381+
}
382+
383+
// Make sure that the first valid response is the "safest" choice
384+
fn ask_response(prompt: &[u8], valid_responses: &[u8]) -> io::Result<u8> {
385+
let stdin = io::stdin();
386+
let stdout = io::stdout();
387+
let mut stderr = io::stderr();
388+
389+
let mut stdin_handle = stdin.lock();
390+
let mut stdout_handle = stdout.lock();
391+
392+
loop {
393+
stdout_handle.write_all(prompt)?;
394+
stdout_handle.flush()?;
395+
396+
let mut input = [0u8];
397+
if let Err(err) = stdin_handle.read_exact(&mut input) {
398+
writeln!(stderr, "visudo: cannot read user input: {err}")?;
399+
return Ok(valid_responses[0]);
400+
}
401+
402+
// read the trailing newline
403+
loop {
404+
let mut skipped = [0u8];
405+
match stdin_handle.read_exact(&mut skipped) {
406+
Ok(()) if &skipped != b"\n" => continue,
407+
_ => break,
408+
}
409+
}
410+
411+
if valid_responses.contains(&input[0]) {
412+
return Ok(input[0]);
413+
} else {
414+
writeln!(
415+
stderr,
416+
"Invalid option: '{}'\n",
417+
str::from_utf8(&input).unwrap_or("<INVALID>")
418+
)?;
419+
}
420+
}
421+
}

0 commit comments

Comments
 (0)