Skip to content

Commit 391fd83

Browse files
authored
Hack hostname Defaults (#1469)
2 parents 1efaa3a + f70884f commit 391fd83

File tree

3 files changed

+49
-26
lines changed

3 files changed

+49
-26
lines changed

src/sudoers/ast.rs

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use super::ast_names::UserFriendly;
22
use super::basic_parser::*;
3-
use super::char_stream::advance;
43
use super::tokens::*;
54
use crate::common::SudoString;
65
use crate::common::{
@@ -465,6 +464,25 @@ impl Parse for (Option<RunAs>, CommandSpec) {
465464
/// "runas" specifier (which has to be placed correctly during the AST analysis phase)
466465
impl Many for (Option<RunAs>, CommandSpec) {}
467466

467+
/// Directives such as "Defaults" and "HostAlias" also could be parsed as part of a
468+
/// UserSpecifier list. We need to detect to give a parse error in cases where the syntax
469+
/// seems to suggest they are used in that manner.
470+
/// Technically, "Defaults@host" is also a valid username, but the logic can disambiguate
471+
/// those cases clearly. E.g. "Defaults@host1,host2 secure_path=/bin/sh" is technically
472+
/// ambiguous but it can be parsed. Time flies like an arrow, fruit flies like a banana.
473+
impl Directive {
474+
fn could_be_username(&self) -> bool {
475+
matches!(
476+
self,
477+
Self::Defaults(_, ConfigScope::Generic)
478+
| Self::UserAlias(_)
479+
| Self::HostAlias(_)
480+
| Self::CmndAlias(_)
481+
| Self::RunasAlias(_)
482+
)
483+
}
484+
}
485+
468486
/// grammar:
469487
/// ```text
470488
/// sudo = permissionspec
@@ -508,7 +526,7 @@ impl Parse for Sudo {
508526
};
509527
}
510528

511-
let start_pos = stream.get_pos();
529+
let start_state = stream.clone();
512530
if stream.peek() == Some('"') {
513531
// a quoted userlist follows; this forces us to read a userlist
514532
let users = expect_nonterminal(stream)?;
@@ -518,9 +536,11 @@ impl Parse for Sudo {
518536
// this could be the start of a Defaults or Alias definition, so distinguish.
519537
// element 1 always exists (parse_list fails on an empty list)
520538
let key = &users[0];
521-
if let Some(directive) = maybe(get_directive(key, stream, start_pos))? {
522-
if users.len() != 1 {
523-
unrecoverable!(pos = start_pos, stream, "invalid user name list");
539+
let start_pos = start_state.get_pos();
540+
if let Some(directive) = maybe(get_directive(key, stream, start_state))? {
541+
if users.len() != 1 && directive.could_be_username() {
542+
// this is here to guard against User_Alias,foo ALIAS=def being accepted
543+
unrecoverable!(pos = start_pos, stream, "ambiguous syntax");
524544
}
525545
make(Sudo::Decl(directive))
526546
} else {
@@ -613,10 +633,10 @@ impl<T> Many for Def<T> {
613633
// I.e. after a valid username has been parsed, we check if it isn't actually a valid start of a
614634
// directive. A more robust solution would be to use the approach taken by the `MetaOrTag` above.
615635

616-
fn get_directive(
636+
fn get_directive<'a>(
617637
perhaps_keyword: &Spec<UserSpecifier>,
618-
stream: &mut CharStream,
619-
begin_pos: (usize, usize),
638+
stream: &mut CharStream<'a>,
639+
begin_state: CharStream<'a>,
620640
) -> Parsed<Directive> {
621641
use super::ast::Directive::*;
622642
use super::ast::Meta::*;
@@ -626,31 +646,32 @@ fn get_directive(
626646
return reject();
627647
};
628648

649+
let begin_pos = begin_state.get_pos();
650+
629651
match keyword.as_str() {
630652
"User_Alias" => make(UserAlias(expect_nonterminal(stream)?)),
631653
"Host_Alias" => make(HostAlias(expect_nonterminal(stream)?)),
632654
"Cmnd_Alias" | "Cmd_Alias" => make(CmndAlias(expect_nonterminal(stream)?)),
633655
"Runas_Alias" => make(RunasAlias(expect_nonterminal(stream)?)),
634656
_ if keyword.starts_with("Defaults") => {
635-
//HACK #1: no space is allowed between "Defaults" and '!>@:'. The below avoids having to
636-
//add "Defaults!" etc as separate tokens; but relying on positional information during
637-
//parsing is of course, cheating.
638-
//HACK #2: '@' can be part of a username, so it will already have been parsed;
639-
//an acceptable hostname is subset of an acceptable username, so that's actually OK.
640-
//This resolves an ambiguity in the grammar similarly to how MetaOrTag does that.
657+
// HACK #1: no space is allowed between "Defaults" and '!>@:'. The below avoids having to
658+
// add "Defaults!" etc as separate tokens; but relying on positional information during
659+
// parsing is of course, cheating.
660+
// HACK #2: '@' can be part of a username, so it will already have been parsed;
661+
// an acceptable hostname is subset of an acceptable username, so that's actually OK.
662+
// This resolves an ambiguity in the grammar similarly to how MetaOrTag does that.
641663
const DEFAULTS_LEN: usize = "Defaults".len();
642664
let allow_scope_modifier = stream.get_pos().0 == begin_pos.0
643665
&& (stream.get_pos().1 - begin_pos.1 == DEFAULTS_LEN
644666
|| keyword.len() > DEFAULTS_LEN);
645667

646668
let scope = if allow_scope_modifier {
647669
if keyword[DEFAULTS_LEN..].starts_with('@') {
648-
let inner_stream = &mut CharStream::new_with_pos(
649-
&keyword[DEFAULTS_LEN + 1..],
650-
advance(begin_pos, DEFAULTS_LEN + 1),
651-
);
670+
// Backtrack and start parsing following the '@' sign
671+
*stream = begin_state;
672+
stream.advance(DEFAULTS_LEN + 1);
652673

653-
ConfigScope::Host(expect_nonterminal(inner_stream)?)
674+
ConfigScope::Host(expect_nonterminal(stream)?)
654675
} else if is_syntax(':', stream)? {
655676
ConfigScope::User(expect_nonterminal(stream)?)
656677
} else if is_syntax('!', stream)? {

src/sudoers/char_stream.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
1+
#[derive(Clone)]
12
pub struct CharStream<'a> {
23
iter: std::iter::Peekable<std::str::Chars<'a>>,
34
line: usize,
45
col: usize,
56
}
67

7-
/// Advance the given position by `n` horizontal steps
8-
pub fn advance(pos: (usize, usize), n: usize) -> (usize, usize) {
9-
(pos.0, pos.1 + n)
10-
}
11-
128
impl<'a> CharStream<'a> {
139
pub fn new_with_pos(src: &'a str, (line, col): (usize, usize)) -> Self {
1410
CharStream {
@@ -21,9 +17,7 @@ impl<'a> CharStream<'a> {
2117
pub fn new(src: &'a str) -> Self {
2218
Self::new_with_pos(src, (1, 1))
2319
}
24-
}
2520

26-
impl CharStream<'_> {
2721
pub fn next_if(&mut self, f: impl FnOnce(char) -> bool) -> Option<char> {
2822
let item = self.iter.next_if(|&c| f(c));
2923
match item {
@@ -52,6 +46,12 @@ impl CharStream<'_> {
5246
pub fn get_pos(&self) -> (usize, usize) {
5347
(self.line, self.col)
5448
}
49+
50+
pub fn advance(&mut self, n: usize) {
51+
for _ in 0..n {
52+
self.next_if(|_| true);
53+
}
54+
}
5555
}
5656

5757
#[cfg(test)]

src/sudoers/test/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,8 @@ fn specific_defaults() {
577577
assert!(try_parse_line("Defaults !/bin/bash").is_none());
578578
assert!(parse_line("Defaults@host !use_pty").is_decl());
579579
assert!(parse_line("Defaults@host!use_pty").is_decl());
580+
assert!(parse_line("Defaults@host,!host2 !use_pty").is_decl());
581+
assert!(parse_line("Defaults@!host,host2!use_pty").is_decl());
580582
assert!(try_parse_line("Defaults @host!use_pty").is_none());
581583
assert!(try_parse_line("Defaults @host !use_pty").is_none());
582584
assert!(parse_line("Defaults:user !use_pty").is_decl());

0 commit comments

Comments
 (0)