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
1 change: 1 addition & 0 deletions crates/bashkit/docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ through configurable limits.
| Pattern substitution (TM-DOS-127) | `${x//*a*/b}` over a long value | Globs run as a size-capped linear-time regex; extglob search capped at 10,000 attempts | MITIGATED |
| Unbounded builtin output (TM-DOS-058) | `seq 1 1000000` produces 1M lines | Add `max_stdout_bytes` limit | **MITIGATED** |
| Silent truncation at builtin caps (TM-DOS-109) | `seq 200000`, an awk loop past its cap, or an oversized `sprintf` expression returns incomplete output with exit 0 | Caps report `<cmd>: <what> limit (<N>) exceeded` on stderr and exit non-zero; awk caps and formatting errors are fatal | **MITIGATED** |
| Syntax diagnostic amplification (TM-DOS-136) | A multiline unexpected token and long `bash -c` ARG0 multiply diagnostic storage before stderr capture | Unexpected-token messages and complete syntax reports are capped at 1 KiB during construction; overflow stops formatting and appends `[diagnostic truncated]`. Short reports retain Bash text and syntax-error exit status | **MITIGATED** |
| In-builtin memory growth (TM-DOS-110) | `awk 'BEGIN { s = "x"; while (1) s = s s }'` or `jq -n '"x" \| until(false; . + .)'` allocates until the host aborts | awk checks each string against a 16 MiB cap before allocating it, caps `$N` field indexes, and caps total variable memory at `max_live_intermediate_bytes` (fatal, exit 2). jq meters every live string, array and object against the same limit and fails before growing (exit 5); non-emitting jq loops and pure recursion stop at the timeout; non-tail recursion hits a live-context ceiling (64) before host stack exhaustion | **MITIGATED** |
| Arrays bypass the retained-memory budget (TM-DOS-114) | Ten array slots each holding a 4 MiB string retain ~40 MB past the 10 MB variable-byte budget | Array keys and values charge `max_total_variable_bytes` alongside scalars, are released on unset/replacement/scope pop, and an over-budget write fails execution. Pending indexed compound-assignment keys, values, fields and containers also hold shared intermediate-memory leases, including repeated subscripts | **MITIGATED** |
| `$(<file)` resource-accounting bypass (TM-DOS-115) | Repeated file-read substitutions skip command limits and accumulate file contents during argument expansion | Optimized reads charge command, session-command, and work budgets; file and accumulated substitution bytes hold live-intermediate leases | **MITIGATED** |
Expand Down
241 changes: 218 additions & 23 deletions crates/bashkit/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
//! - No leakage of sensitive information (paths, memory addresses, secrets)
//! - Clear categorization for programmatic handling

// Decision: syntax diagnostics have a fixed 1 KiB construction cap, independent
// of stderr capture. Untrusted token lines and shell names can otherwise multiply
// allocations before an execution budget or asynchronous timeout can intervene.

use std::fmt::{self, Write};

use crate::limits::LimitExceeded;
use thiserror::Error;

Expand Down Expand Up @@ -141,7 +147,8 @@ impl Error {
/// bash's report of this syntax error in `source`, as read by `who`
/// (`bash: -c`, a script path): `who: line N: message`, followed by the
/// offending source line for a `near unexpected token` error. `None` for
/// errors other than [`Error::Parse`].
/// errors other than [`Error::Parse`]. Reports are limited to 1 KiB,
/// including `[diagnostic truncated]` when the complete report cannot fit.
///
/// ```
/// let err = bashkit::parser::Parser::new("if then fi").parse().unwrap_err();
Expand Down Expand Up @@ -288,34 +295,132 @@ pub(crate) fn syntax_report(
line: usize,
shift: usize,
) -> String {
let message = if message.starts_with("syntax error") || message.starts_with("unexpected EOF") {
message.to_string()
// THREAT[TM-DOS-136]: stop before growth, including temporary formatting.
// The shell name (`-c` ARG0) is attacker-controlled and stamped on every
// report line: truncate it first so a long ARG0 cannot evict the
// structural payload (`syntax error ...`, token excerpt, marker) out of
// the byte budget. Short names print verbatim.
let shell: std::borrow::Cow<'_, str> = if who.chars().count() > MAX_SHELL_NAME_CHARS {
let head_len = MAX_SHELL_NAME_CHARS - SHELL_NAME_TAIL_CHARS;
let mut head_end = head_len;
while !who.is_char_boundary(head_end) {
head_end -= 1;
}
let mut tail_start = who.len();
for _ in 0..SHELL_NAME_TAIL_CHARS {
if tail_start == 0 {
break;
}
tail_start -= 1;
while !who.is_char_boundary(tail_start) {
tail_start -= 1;
}
}
std::borrow::Cow::Owned(format!("{}...{}", &who[..head_end], &who[tail_start..]))
} else {
format!("syntax error: {message}")
std::borrow::Cow::Borrowed(who)
};
if line == 0 {
return format!("{who}: {message}\n");
let who = shell.as_ref();
let mut out = Diagnostic::new();
let _ = (|| -> fmt::Result {
let prefix = if message.starts_with("syntax error") || message.starts_with("unexpected EOF")
{
""
} else {
"syntax error: "
};
if line == 0 {
return writeln!(out, "{who}: {prefix}{message}");
}
let at = line.saturating_add(shift);
let mut lines = message.lines();
let first = lines.next().unwrap_or_default();
writeln!(out, "{who}: line {at}: {prefix}{first}")?;
if first.contains("near unexpected token")
&& let Some(text) = source.lines().nth(line - 1)
{
writeln!(out, "{who}: line {at}: `{text}'")?;
}
for more in lines {
let further = more.strip_prefix("line ").and_then(|rest| {
let (n, text) = rest.split_once(": ")?;
Some((n.parse::<usize>().ok()?.saturating_add(shift), text))
});
match further {
Some((n, text)) => writeln!(out, "{who}: line {n}: {text}")?,
None => writeln!(out, "{who}: line {at}: {more}")?,
}
}
Ok(())
})();
out.finish()
}

const MAX_DIAGNOSTIC_BYTES: usize = 1024;
/// Display width for the shell name (`-c` ARG0) inside one diagnostic.
/// Short names print verbatim; longer ones are cut here so a long ARG0
/// cannot evict the structural payload out of the byte budget. The cut keeps
/// the head and the tail: the tail carries attribution suffixes such as
/// `: eval` (bash prints `<script>: eval: line N ...`). The width sits well
/// above realistic script paths (the oracle pins those byte-exact) while the
/// 1 KiB total stays the enforced bound.
const MAX_SHELL_NAME_CHARS: usize = 256;
/// Tail kept by the shell-name cut, so attribution suffixes survive.
const SHELL_NAME_TAIL_CHARS: usize = 16;
const DIAGNOSTIC_TRUNCATED: &str = "[diagnostic truncated]\n";

/// Fixed-capacity writer: formatting cannot allocate the full untrusted text.
struct Diagnostic {
text: String,
truncated: bool,
}

impl Diagnostic {
fn new() -> Self {
Self {
text: String::with_capacity(MAX_DIAGNOSTIC_BYTES),
truncated: false,
}
}
let at = line + shift;
let mut lines = message.lines();
let first = lines.next().unwrap_or_default();
let mut out = format!("{who}: line {at}: {first}\n");
if first.contains("near unexpected token")
&& let Some(text) = source.lines().nth(line - 1)
{
out.push_str(&format!("{who}: line {at}: `{text}'\n"));

fn finish(mut self) -> String {
if self.truncated {
let mut end = self
.text
.len()
.min(MAX_DIAGNOSTIC_BYTES - DIAGNOSTIC_TRUNCATED.len());
while !self.text.is_char_boundary(end) {
end -= 1;
}
self.text.truncate(end);
self.text.push_str(DIAGNOSTIC_TRUNCATED);
}
self.text
}
for more in lines {
let further = more.strip_prefix("line ").and_then(|rest| {
let (n, text) = rest.split_once(": ")?;
Some((n.parse::<usize>().ok()? + shift, text))
});
match further {
Some((n, text)) => out.push_str(&format!("{who}: line {n}: {text}\n")),
None => out.push_str(&format!("{who}: line {at}: {more}\n")),
}

impl Write for Diagnostic {
fn write_str(&mut self, text: &str) -> fmt::Result {
let remaining = MAX_DIAGNOSTIC_BYTES - self.text.len();
if text.len() > remaining {
let mut end = remaining;
while !text.is_char_boundary(end) {
end -= 1;
}
self.text.push_str(&text[..end]);
self.truncated = true;
return Err(fmt::Error);
}
self.text.push_str(text);
Ok(())
}
out
}

/// Bound parser token diagnostics at their creation, before report formatting.
pub(crate) fn bounded_diagnostic(args: fmt::Arguments<'_>) -> String {
let mut out = Diagnostic::new();
let _ = out.write_fmt(args);
out.finish()
}

#[cfg(test)]
Expand Down Expand Up @@ -359,6 +464,96 @@ mod tests {
assert_eq!(io_error_reason(&err), "execution error: boom");
}

#[test]
fn syntax_report_bounds_multiline_token_and_shell_name() {
let source = format!("for x \"{}\"", "a\n".repeat(16));
let error = crate::parser::Parser::new(&source).parse().unwrap_err();
let report = error.syntax_report(&"x".repeat(256), &source).unwrap();
assert!(report.len() <= 1024, "report was {} bytes", report.len());
assert!(report.ends_with("[diagnostic truncated]\n"));
}

#[test]
fn syntax_report_bounds_each_untrusted_component() {
for (who, source, message, line) in [
("bash".to_owned(), String::new(), "界".repeat(2048), 0),
(
"bash".into(),
"界".repeat(2048),
"syntax error near unexpected token `x'".into(),
1,
),
(
"bash".into(),
String::new(),
format!("syntax error\n{}", "line 1: bad\n".repeat(2048)),
1,
),
] {
let report = syntax_report(&who, &source, &message, line, 0);
assert!(report.len() <= MAX_DIAGNOSTIC_BYTES);
assert_eq!(report.capacity(), MAX_DIAGNOSTIC_BYTES);
assert!(report.ends_with(DIAGNOSTIC_TRUNCATED));
}
}

#[test]
fn syntax_report_long_shell_name_keeps_payload() {
// A long `-c` ARG0 is truncated first: the structural payload must
// survive inside the byte budget, with the marker only if the rest
// still overflows.
let report = syntax_report(&"B".repeat(8192), "", "syntax error", 1, 1);
assert!(report.len() <= MAX_DIAGNOSTIC_BYTES);
assert!(report.contains("syntax error"));
assert!(report.contains("..."));
let short = syntax_report("CASE", "", "syntax error", 1, 1);
assert!(short.contains("CASE"), "short names print verbatim");
let layered = syntax_report(
&format!("{}: eval", "S".repeat(256)),
"",
"syntax error",
1,
1,
);
assert!(
layered.contains(": eval"),
"attribution suffix survives the shell-name cut"
);
assert!(layered.len() <= MAX_DIAGNOSTIC_BYTES);
}

#[test]
fn syntax_report_preserves_line_numbers_and_exact_boundary() {
assert_eq!(
syntax_report("bash", "", "unexpected EOF\nline 7: unmatched quote", 2, 3),
"bash: line 5: unexpected EOF\nbash: line 10: unmatched quote\n"
);
let message = format!("syntax error: {}", "x".repeat(1003));
let report = syntax_report("bash", "", &message, 0, 0);
assert_eq!(report.len(), MAX_DIAGNOSTIC_BYTES);
assert!(!report.contains(DIAGNOSTIC_TRUNCATED));
assert_eq!(
syntax_report("bash", "", "bad input", 0, 0),
"bash: syntax error: bad input\n"
);
assert!(
syntax_report("bash", "", "syntax error\nline 1: extra", 1, usize::MAX)
.contains(&usize::MAX.to_string())
);
}

#[test]
fn parser_bounds_unexpected_token_before_copying() {
let source = format!("for x \"{}\"", "é\n".repeat(2048));
let Error::Parse { message, .. } = crate::parser::Parser::new(&source).parse().unwrap_err()
else {
panic!("expected parse error")
};
assert!(message.len() <= MAX_DIAGNOSTIC_BYTES);
assert_eq!(message.capacity(), MAX_DIAGNOSTIC_BYTES);
assert!(message.ends_with(DIAGNOSTIC_TRUNCATED));
}

use super::*;

#[test]
Expand Down
5 changes: 4 additions & 1 deletion crates/bashkit/src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,10 @@ impl<'a> Parser<'a> {
let line = self.current_span.start.line;
let near = |token: &str| {
Some(Error::parse_at(
format!("syntax error near unexpected token `{token}'"),
// THREAT[TM-DOS-136]: a quoted token may span the whole input.
crate::error::bounded_diagnostic(format_args!(
"syntax error near unexpected token `{token}'"
)),
line,
self.current_span.start.column,
))
Expand Down
60 changes: 60 additions & 0 deletions crates/bashkit/tests/integration/partial_parse_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,63 @@ async fn bash_n_still_rejects_whole_script() {
.unwrap();
assert_eq!(r.stdout, "rc=2\n");
}

/// A multiline token and long $0 must not multiply diagnostic storage.
#[tokio::test]
async fn child_syntax_report_is_bounded() {
let script = format!("for x \"{}\"", "a\n".repeat(16));
let request = format!("bash -c '{script}' '{}'", "x".repeat(256));
let mut bash = Bash::new();
let result = bash.exec(&request).await.unwrap();
assert_eq!(result.exit_code, 2);
assert!(result.stdout.is_empty());
assert!(
result.stderr.len() <= 1024,
"report was {} bytes",
result.stderr.len()
);
assert!(result.stderr.ends_with("[diagnostic truncated]\n"));
assert_eq!(bash.exec("echo alive").await.unwrap().stdout, "alive\n");
}

#[tokio::test]
async fn small_child_syntax_reports_match_bash() {
// Host /bin/bash varies by platform (macOS bash 3.2 reports `-c` input as
// `line 0`, modern GNU bash reports `line 1`, matching our parser), so
// compare the diagnostic shape with the line number normalized away.
fn normalize_line(input: &[u8]) -> Vec<u8> {
let mut out = input.to_vec();
let mut i = 0;
while i + 5 < out.len() {
if out[i..].starts_with(b"line ") {
let mut j = i + 5;
while j < out.len() && out[j].is_ascii_digit() {
j += 1;
}
if j > i + 5 {
out.splice(i + 5..j, [b'#']);
}
}
i += 1;
}
out
}
for script in ["if then fi", "echo kept\nif then", "for x ; wat"] {
let expected = std::process::Command::new("bash")
.args(["-c", script, "CASE"])
.output()
.unwrap();
let mut bash = Bash::new();
let result = bash
.exec(&format!("bash -c '{script}' CASE"))
.await
.unwrap();
assert_eq!(result.exit_code, expected.status.code().unwrap());
assert_eq!(result.stdout.as_bytes(), expected.stdout);
assert_eq!(
normalize_line(result.stderr.as_bytes()),
normalize_line(&expected.stderr),
"script: {script:?}"
);
}
}
Loading
Loading