Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion crates/bashkit/src/parser/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1790,6 +1790,20 @@ impl<'a> Lexer<'a> {
/// Read here document content with optional leading-tab stripping on the
/// delimiter match (for `<<-`).
pub fn read_heredoc_with_strip(&mut self, delimiter: &str, strip_tabs: bool) -> String {
self.read_heredoc_with_strip_metered(delimiter, strip_tabs)
.0
}

/// Read here document content and report rest-of-line work for parser fuel.
/// THREAT[TM-DOS-064]: Long heredoc command-line suffixes are re-injected so
/// list/pipeline tokens after `<<EOF` stay visible. Charging the suffix length
/// to parser fuel prevents chained heredocs from repeatedly copying suffixes
/// outside resource accounting.
pub(crate) fn read_heredoc_with_strip_metered(
&mut self,
delimiter: &str,
strip_tabs: bool,
) -> (String, usize) {
let mut content = String::new();
let mut current_line = String::new();

Expand Down Expand Up @@ -1866,14 +1880,15 @@ impl<'a> Lexer<'a> {

// Re-inject saved rest-of-line so subsequent tokens (pipes, commands, etc.)
// are visible to the parser. Add a newline so the tokenizer sees the line break.
let rest_of_line_chars = rest_of_line.chars().count();
if !rest_of_line.is_empty() {
for ch in rest_of_line.chars() {
self.reinject_buf.push_back(ch);
}
self.reinject_buf.push_back('\n');
}

content
(content, rest_of_line_chars)
}
}

Expand Down
47 changes: 42 additions & 5 deletions crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,27 @@ impl<'a> Parser<'a> {
Ok(())
}

/// Consume multiple parser fuel units for lexer work that can scale with input size.
/// THREAT[TM-DOS-064]: Heredoc rest-of-line re-injection copies command suffixes;
/// charge each copied character so repeated heredocs cannot hide quadratic work
/// outside parser fuel accounting.
fn tick_units(&mut self, units: usize) -> Result<()> {
if let Some(timeout) = self.timeout
&& self.started_at.elapsed() > timeout
{
return Err(Error::ResourceLimit(LimitExceeded::ParserTimeout(timeout)));
}
if self.fuel < units {
let used = self.max_fuel;
return Err(Error::parse(format!(
"parser fuel exhausted ({} operations, max {})",
used, self.max_fuel
)));
}
self.fuel -= units;
Ok(())
}

/// Push nesting depth and check limit
fn push_depth(&mut self) -> Result<()> {
self.current_depth += 1;
Expand Down Expand Up @@ -404,7 +425,7 @@ impl<'a> Parser<'a> {
}

/// Parse redirections that follow a compound command (>, >>, 2>, etc.)
fn parse_trailing_redirects(&mut self) -> Vec<Redirect> {
fn parse_trailing_redirects(&mut self) -> Result<Vec<Redirect>> {
let mut redirects = Vec::new();
loop {
match &self.current_token {
Expand Down Expand Up @@ -579,7 +600,10 @@ impl<'a> Parser<'a> {
| Some(tokens::Token::QuotedGlobWord(w)) => (w.clone(), true),
_ => break,
};
let content = self.lexer.read_heredoc_with_strip(&delimiter, strip_tabs);
let (content, rest_of_line_chars) = self
.lexer
.read_heredoc_with_strip_metered(&delimiter, strip_tabs);
self.tick_units(rest_of_line_chars)?;
let content = if strip_tabs {
let had_trailing_newline = content.ends_with('\n');
let mut stripped: String = content
Expand Down Expand Up @@ -618,7 +642,7 @@ impl<'a> Parser<'a> {
_ => break,
}
}
redirects
Ok(redirects)
}

/// Parse a compound command and any trailing redirections
Expand All @@ -627,7 +651,7 @@ impl<'a> Parser<'a> {
parser: impl FnOnce(&mut Self) -> Result<CompoundCommand>,
) -> Result<Option<Command>> {
let compound = parser(self)?;
let redirects = self.parse_trailing_redirects();
let redirects = self.parse_trailing_redirects()?;
Ok(Some(Command::Compound(compound, redirects)))
}

Expand Down Expand Up @@ -2188,7 +2212,10 @@ impl<'a> Parser<'a> {
_ => return Err(Error::parse("expected delimiter after <<".to_string())),
};

let content = self.lexer.read_heredoc_with_strip(&delimiter, strip_tabs);
let (content, rest_of_line_chars) = self
.lexer
.read_heredoc_with_strip_metered(&delimiter, strip_tabs);
self.tick_units(rest_of_line_chars)?;

// Strip leading tabs for <<-
let content = if strip_tabs {
Expand Down Expand Up @@ -3950,6 +3977,16 @@ mod tests {
}
}

#[test]
fn test_chained_heredoc_reinjection_consumes_parser_fuel() {
let script = ": <<E && : <<E && : <<E\nE\nE\nE\n";
let err = Parser::with_fuel(script, 12).parse().unwrap_err();
assert!(
err.to_string().contains("parser fuel exhausted"),
"expected heredoc rest-of-line reinjection to consume parser fuel, got: {err}"
);
}

#[test]
fn test_top_level_reserved_word_errors_immediately() {
let parser = Parser::with_fuel("fi", usize::MAX);
Expand Down
3 changes: 3 additions & 0 deletions specs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ runaway scripts without permanently breaking the session.
| TM-DOS-021 | Command sub nesting | `$($($($())))` | Child parsers inherit remaining depth budget + fuel from parent | **MITIGATED** |
| TM-DOS-022 | Parser recursion | Deeply nested `(((())))` | `max_ast_depth` limit (100) + `HARD_MAX_AST_DEPTH` cap (100) | **MITIGATED** |
| TM-DOS-026 | Arithmetic recursion | `$(((((((...)))))))` deeply nested parens | `MAX_ARITHMETIC_DEPTH` limit (50) | **MITIGATED** |
| TM-DOS-064 | Heredoc suffix re-injection CPU amplification | Many `: <<E && : <<E ...` heredocs on one logical line repeatedly copy command-line suffixes | `read_heredoc_with_strip_metered` reports re-injected suffix length; parser charges it to `max_parser_operations` fuel | **MITIGATED** |

**Current Risk**: LOW - Both execution and parser protected

Expand All @@ -253,6 +254,7 @@ max_ast_depth: 100, // Parser recursion (TM-DOS-022)
// depth budget and fuel from parent parser (parser/mod.rs lines 1553, 1670)
// TM-DOS-026: Arithmetic evaluator tracks recursion depth, capped at 50
// (interpreter/mod.rs MAX_ARITHMETIC_DEPTH)
// TM-DOS-064: Heredoc rest-of-line re-injection is charged to parser fuel
```

**History** (TM-DOS-021): Previously marked MITIGATED but child parsers created via
Expand Down Expand Up @@ -1486,6 +1488,7 @@ This section maps former vulnerability IDs to the new threat ID scheme and track
| Session-level cumulative counters | TM-ISO-005 | `SessionLimits` caps cumulative commands and `exec()` calls across the lifetime of a `Bash` instance | **MITIGATED** |
| Per-instance memory budget | TM-ISO-006 | `MemoryLimits` capping variable count, total bytes, array entries, function count, function body bytes | **MITIGATED** |
| jq file binding amplification | TM-DOS-062 | `MAX_FILE_VAR_REQUESTS` and `MAX_FILE_VAR_BYTES` bound `--rawfile` / `--slurpfile` globals | **MITIGATED** |
| Heredoc suffix re-injection CPU amplification | TM-DOS-064 | Charge re-injected heredoc rest-of-line suffix length to parser fuel | **MITIGATED** |

---

Expand Down
Loading