Skip to content

Commit a133f0e

Browse files
authored
Add support for remote sudoers (#1539)
2 parents 2e105fa + 8945438 commit a133f0e

8 files changed

Lines changed: 286 additions & 22 deletions

File tree

docs/man/sudoers.5.man

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,9 @@ The only supported use is \[cq]*\[cq] as the final argument to indicate
599599
.SS Including other files from within sudoers
600600
It is possible to include other sudoers files from within the sudoers
601601
file currently being parsed using the \f[I]\[at]include\f[R] and
602-
\f[I]\[at]includedir\f[R] directives.
602+
\f[I]\[at]includedir\f[R] directives; or the contents provided by an
603+
application over a unix domain socket using the \f[I]\[at]socket\f[R]
604+
directive.
603605
For compatibility with Todd Miller\[cq]s sudo versions prior to 1.9.1,
604606
\f[I]#include\f[R] and \f[I]#includedir\f[R] are also accepted.
605607
.PP
@@ -665,6 +667,24 @@ syntax error.
665667
It is still possible to run visudo with the \-f flag to edit the files
666668
directly, but this will not catch the redefinition of an alias that is
667669
also present in a different file.
670+
.PP
671+
When managing enterprise-wide sudoers rules, it is sometimes preferable
672+
to store them in a centralized repository. The \[at]socket directive can be
673+
used to include the contents provided by a server application over a unix
674+
domain socket. For example, providing:
675+
.IP
676+
.EX
677+
\[at]socket /var/run/providers/sudoers.socket
678+
.EE
679+
.PP
680+
will make sudo open that socket, read the rules and close it, as if it
681+
was an included file. The rules must follow the same syntax used in the
682+
sudoers files. There is, however, one exception: when reading from a
683+
socket, the \[at]include, \[at]includedir and \[at]socket directives
684+
are not accepted.
685+
.PP
686+
Please note that the contents read from a socket are immutable from
687+
sudo's point of view and visudo will not be able to edit them.
668688
.SS Other special characters and reserved words
669689
The pound sign (`#') is used to indicate a comment (unless it is part of
670690
a #include directive or unless it occurs in the context of a user name

docs/man/sudoers.5.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ Wildcards in command line arguments are not supported---using these in original
323323

324324
## Including other files from within sudoers
325325

326-
It is possible to include other sudoers files from within the sudoers file currently being parsed using the *@include* and *@includedir* directives. For compatibility with Todd Miller's sudo versions prior to 1.9.1, *#include* and *#includedir* are also accepted.
326+
It is possible to include other sudoers files from within the sudoers file currently being parsed using the *@include* and *@includedir* directives; or the contents provided by an application over a unix domain socket using the *@socket* directive. For compatibility with Todd Miller's sudo versions prior to 1.9.1, *#include* and *#includedir* are also accepted.
327327

328328
An include file can be used, for example, to keep a site-wide sudoers file in addition to a local, per-machine file. For the sake of this example the site-wide sudoers file will be /etc/sudoers and the per-machine one will be /etc/sudoers.local. To include /etc/sudoers.local from within /etc/sudoers one would use the following line in /etc/sudoers:
329329

@@ -343,6 +343,14 @@ sudo will suspend processing of the current file and read each file in /etc/sudo
343343

344344
Note that unlike files included via @include, visudo will not edit the files in a @includedir directory unless one of them contains a syntax error. It is still possible to run visudo with the -f flag to edit the files directly, but this will not catch the redefinition of an alias that is also present in a different file.
345345

346+
When managing enterprise-wide sudoers rules, it is sometimes preferable to store them in a centralized repository. The @socket directive can be used to include the contents provided by a server application over a unix domain socket. For example, providing:
347+
348+
@socket /var/run/providers/sudoers.socket
349+
350+
will make sudo open that socket, read the rules and close it, as if it was an included file. The rules must follow the same syntax used in the sudoers files. There is, however, one exception: when reading from a socket, the @include, @includedir and @socket directives are not accepted.
351+
352+
Please note that the contents read from a socket are immutable from sudo's point of view and visudo will not be able to edit them.
353+
346354
## Other special characters and reserved words
347355

348356
The pound sign (‘#’) is used to indicate a comment (unless it is part of a #include directive or unless it occurs in the context of a user name and is followed by one or more digits, in which case it is treated as a user-ID). Both the comment character and any text after it, up to the end of the line, are ignored.

src/common/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pub const HARDENED_ENUM_VALUE_1: u32 = 0xad5d6da; // 101011010101110101101101101
2525
pub const HARDENED_ENUM_VALUE_2: u32 = 0x69d61fc8; // 1101001110101100001111111001000
2626
pub const HARDENED_ENUM_VALUE_3: u32 = 0x1629e037; // 0010110001010011110000000110111
2727
pub const HARDENED_ENUM_VALUE_4: u32 = 0x1fc8d3ac; // 11111110010001101001110101100
28+
pub const HARDENED_ENUM_VALUE_5: u32 = 0x70372c52; // 1110000001101110010110001010011
2829

2930
// FIXME replace with OsStr::display() once our MSRV is 1.87
3031
pub(crate) struct DisplayOsStr<'a>(pub(crate) &'a OsStr);

src/sudoers/ast.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use super::tokens::*;
44
use crate::common::SudoString;
55
use crate::common::{
66
HARDENED_ENUM_VALUE_0, HARDENED_ENUM_VALUE_1, HARDENED_ENUM_VALUE_2, HARDENED_ENUM_VALUE_3,
7-
HARDENED_ENUM_VALUE_4,
7+
HARDENED_ENUM_VALUE_4, HARDENED_ENUM_VALUE_5,
88
};
99
use crate::defaults;
1010

@@ -158,7 +158,8 @@ pub enum Sudo {
158158
Decl(Directive) = HARDENED_ENUM_VALUE_1,
159159
Include(String, Span) = HARDENED_ENUM_VALUE_2,
160160
IncludeDir(String, Span) = HARDENED_ENUM_VALUE_3,
161-
LineComment = HARDENED_ENUM_VALUE_4,
161+
Remote(String, Span) = HARDENED_ENUM_VALUE_4,
162+
LineComment = HARDENED_ENUM_VALUE_5,
162163
}
163164

164165
/// grammar:
@@ -592,6 +593,10 @@ fn parse_include(stream: &mut CharStream) -> Parsed<Sudo> {
592593
let (path, span) = get_path(stream, key_pos)?;
593594
Sudo::IncludeDir(path, span)
594595
}
596+
Some(Username(key)) if key == "socket" => {
597+
let (path, span) = get_path(stream, key_pos)?;
598+
Sudo::Remote(path, span)
599+
}
595600
_ => unrecoverable!(pos = key_pos, stream, "unknown directive"),
596601
};
597602

src/sudoers/mod.rs

Lines changed: 113 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod tokens;
1111

1212
use std::collections::{HashMap, HashSet};
1313
use std::ffi::OsString;
14+
use std::fmt;
1415
use std::io;
1516
use std::path::{Path, PathBuf};
1617

@@ -412,6 +413,11 @@ fn open_subsudoers(path: &Path) -> io::Result<Vec<basic_parser::Parsed<Sudo>>> {
412413
read_sudoers(source)
413414
}
414415

416+
fn open_remote_sudoers(path: &Path) -> io::Result<Vec<basic_parser::Parsed<Sudo>>> {
417+
let source = audit::secure_open_remote_sudoers(path)?;
418+
read_sudoers(source)
419+
}
420+
415421
// note: trying to DRY using GAT's is tempting but doesn't make the code any shorter
416422

417423
#[derive(Default)]
@@ -686,6 +692,52 @@ fn analyze(
686692

687693
let mut result: Sudoers = Default::default();
688694

695+
/// The IncludeState allows to tell whether including new files is allowed.
696+
/// There are two situations:
697+
/// - inclusion is forbidden (as when the @socket directive is used)
698+
/// - inclusion is allowed but we need to ensure we didn't go beyond the limit
699+
#[derive(Clone, Copy)]
700+
enum IncludeState {
701+
Forbidden,
702+
Allowed(u8),
703+
}
704+
705+
impl IncludeState {
706+
#[inline]
707+
fn inc(&mut self) -> &mut Self {
708+
if let IncludeState::Allowed(x) = self {
709+
*x += 1;
710+
}
711+
self
712+
}
713+
714+
#[inline]
715+
fn limit_reached(&self) -> bool {
716+
match self {
717+
IncludeState::Forbidden => true,
718+
IncludeState::Allowed(x) => *x >= INCLUDE_LIMIT,
719+
}
720+
}
721+
}
722+
723+
/// The directive that produced the inclusion.
724+
enum IncludeDirective {
725+
Include,
726+
IncludeDir,
727+
Remote,
728+
}
729+
730+
impl fmt::Display for IncludeDirective {
731+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
732+
let s = match self {
733+
IncludeDirective::Include => "@include",
734+
IncludeDirective::IncludeDir => "@includedir",
735+
IncludeDirective::Remote => "@socket",
736+
};
737+
write!(f, "{s}")
738+
}
739+
}
740+
689741
fn resolve_relative(base: &Path, path: impl AsRef<Path>) -> PathBuf {
690742
if path.as_ref().is_relative() {
691743
// there should always be a parent since we start with /etc/sudoers, and make every other path
@@ -704,26 +756,40 @@ fn analyze(
704756
span: Span,
705757
path: &Path,
706758
diagnostics: &mut Vec<Error>,
707-
count: &mut u8,
759+
include_state: &mut IncludeState,
760+
include_source: IncludeDirective,
708761
) {
709-
if *count >= INCLUDE_LIMIT {
762+
// IncludeState::limit_reached() will also detect forbidden includes
763+
if include_state.limit_reached() {
764+
let message = match include_state {
765+
IncludeState::Allowed(_) => {
766+
format!("include file limit reached opening '{}'", path.display())
767+
}
768+
IncludeState::Forbidden => {
769+
format!("{} forbidden at this stage", include_source)
770+
}
771+
};
710772
diagnostics.push(Error {
711773
source: Some(parent.to_owned()),
712774
location: Some(span),
713-
message: format!("include file limit reached opening '{}'", path.display()),
714-
})
715-
// FIXME: this will cause an error in `visudo` if we open a non-privileged sudoers file
716-
// that includes another non-privileged sudoer files.
775+
message,
776+
});
717777
} else {
718-
match open_subsudoers(path) {
719-
Ok(subsudoer) => {
720-
*count += 1;
721-
process(cfg, path, subsudoer, diagnostics, count)
722-
}
778+
let (res, next_state, kind) = match include_source {
779+
IncludeDirective::Remote => (
780+
open_remote_sudoers(path),
781+
&mut IncludeState::Forbidden,
782+
"socket",
783+
),
784+
_ => (open_subsudoers(path), include_state.inc(), "file"),
785+
};
786+
787+
match res {
788+
Ok(subsudoer) => process(cfg, path, subsudoer, diagnostics, next_state),
723789
Err(e) => {
724790
let message = if e.kind() == io::ErrorKind::NotFound {
725791
// improve the error message in this case
726-
format!("cannot open sudoers file '{}'", path.display())
792+
format!("cannot open sudoers {} '{}'", kind, path.display())
727793
} else {
728794
e.to_string()
729795
};
@@ -743,7 +809,7 @@ fn analyze(
743809
cur_path: &Path,
744810
sudoers: impl IntoIterator<Item = basic_parser::Parsed<Sudo>>,
745811
diagnostics: &mut Vec<Error>,
746-
safety_count: &mut u8,
812+
include_state: &mut IncludeState,
747813
) {
748814
for item in sudoers {
749815
match item {
@@ -788,9 +854,33 @@ fn analyze(
788854
span,
789855
&resolve_relative(cur_path, path),
790856
diagnostics,
791-
safety_count,
857+
include_state,
858+
IncludeDirective::Include,
792859
),
793860

861+
Sudo::Remote(path, span) => {
862+
let socket_path = Path::new(&path);
863+
if socket_path.is_relative() {
864+
diagnostics.push(Error {
865+
source: Some(cur_path.to_owned()),
866+
location: Some(span),
867+
message: format!(
868+
"cannot open socket {path}: path must be absolute"
869+
),
870+
});
871+
}
872+
873+
include(
874+
cfg,
875+
cur_path,
876+
span,
877+
socket_path,
878+
diagnostics,
879+
include_state,
880+
IncludeDirective::Remote,
881+
);
882+
}
883+
794884
Sudo::IncludeDir(path, span) => {
795885
if path.contains("%h") {
796886
diagnostics.push(Error {
@@ -832,7 +922,8 @@ fn analyze(
832922
span,
833923
file.as_ref(),
834924
diagnostics,
835-
safety_count,
925+
include_state,
926+
IncludeDirective::IncludeDir,
836927
)
837928
}
838929
}
@@ -863,7 +954,13 @@ fn analyze(
863954
}
864955

865956
let mut diagnostics = vec![];
866-
process(&mut result, path, sudoers, &mut diagnostics, &mut 0);
957+
process(
958+
&mut result,
959+
path,
960+
sudoers,
961+
&mut diagnostics,
962+
&mut IncludeState::Allowed(0),
963+
);
867964

868965
let alias = &mut result.aliases;
869966
alias.user.0 = sanitize_alias_table(&alias.user.1, &mut diagnostics);

src/system/audit.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
use std::collections::HashSet;
22
use std::ffi::{CStr, CString, c_int, c_uint};
3-
use std::fs::{DirBuilder, File, Metadata, OpenOptions};
4-
use std::io::{self, Error, ErrorKind};
3+
use std::fs::{self, DirBuilder, File, Metadata, OpenOptions};
4+
use std::io::{self, BufReader, Error, ErrorKind};
5+
use std::net::Shutdown;
56
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd};
67
use std::os::unix::{
78
ffi::OsStrExt,
89
fs::{DirBuilderExt, MetadataExt, PermissionsExt},
10+
net::UnixStream,
911
prelude::OpenOptionsExt,
1012
};
1113
use std::path::{Component, Path};
@@ -137,6 +139,10 @@ pub fn secure_open_sudoers(path: impl AsRef<Path>, check_parent_dir: bool) -> io
137139
secure_open_impl(path.as_ref(), &mut open_options, check_parent_dir, false)
138140
}
139141

142+
pub fn secure_open_remote_sudoers(path: impl AsRef<Path>) -> io::Result<BufReader<UnixStream>> {
143+
secure_open_socket_impl(path.as_ref())
144+
}
145+
140146
/// Open a timestamp cookie file using various security checks
141147
pub fn secure_open_cookie_file(path: impl AsRef<Path>) -> io::Result<File> {
142148
let mut open_options = OpenOptions::new();
@@ -234,6 +240,19 @@ fn secure_open_impl(
234240
Ok(file)
235241
}
236242

243+
// Open the socket at path, provided that it is "secure".
244+
// "Secure" means that it passes the `checks` function above.
245+
fn secure_open_socket_impl(path: &Path) -> io::Result<BufReader<UnixStream>> {
246+
let meta = fs::metadata(path)?;
247+
checks(path, meta)?;
248+
249+
let stream = UnixStream::connect(path)?;
250+
stream.shutdown(Shutdown::Write)?;
251+
let reader = BufReader::new(stream);
252+
253+
Ok(reader)
254+
}
255+
237256
fn open_at(parent: BorrowedFd, file_name: &CStr, create: bool) -> io::Result<OwnedFd> {
238257
let flags = if create {
239258
libc::O_NOFOLLOW | libc::O_RDWR | libc::O_CREAT

test-framework/e2e-tests/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
mod pty;
44
mod regression;
55
mod su;
6+
mod sudoers;
67

78
const USERNAME: &str = "ferris";
89

0 commit comments

Comments
 (0)