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 @@ -82,6 +82,7 @@ through configurable limits.
| History files (TM-DOS-131) | `history -a` loops, huge `$HISTSIZE`, giant `$HISTFILE` | History list bounded by `max_history_entries`/`max_history_bytes`; `$HISTFILE` reads refused past `max_input_bytes`; VFS only | MITIGATED |
| Completion specs and bindings (TM-DOS-132) | `complete`/`bind` in a loop | 1024 specs (64 KiB each) and 1024 binding changes (4 KiB each) per interpreter; never shared between instances | MITIGATED |
| Prompt recursion (TM-DOS-133) | `x='${x@P}'; echo "${x@P}"`, mutual cycles or substitution-mediated cycles | Cumulative prompt depth capped at `min(max_ast_depth, 8)`; shared work, aggregate input, cancellation and deadline checks on each prompt; descendant shells inherit depth | MITIGATED |
| Indirect operand recursion (TM-DOS-134) | `r='a[0]:-${!r}]'` re-entering `${!...}` through an unset-element default | Bracket targets truncate at the first `]` (bash parity: `a[0]` resolves, rest ignored), so trailing operands never reach the indirect re-parse | MITIGATED |

**Stack Overflow / Recursion:**

Expand Down
15 changes: 15 additions & 0 deletions crates/bashkit/src/interpreter/expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2624,6 +2624,21 @@ impl Interpreter {
.is_some_and(|f| f.is_ascii_alphabetic() || f == '_')
&& c.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
};
// TM-RES-INDIRECT: an indirect array target ends at the first `]`
// after `[` (real bash resolves `a[0]` from `a[0]:-x]` and ignores
// the rest). Truncating before validation keeps trailing
// operator/nesting text — including a nested `${!...}` — from ever
// reaching the desugar re-parse below, which re-entered indirect
// expansion without bound (`r='a[0]:-${!r}]'` overflowed the stack).
// Targets without a sub-`]` (plain names, `a[$i]`, `a[@]`) pass
// through unchanged.
let value = match value.find('[') {
Some(open) => match value[open..].find(']') {
Some(rel) => value[..open + rel + 1].to_string(),
None => value,
},
None => value,
};
let valid = ident(&value)
|| (!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()))
|| matches!(value.as_str(), "@" | "*" | "#" | "?" | "$" | "!" | "-")
Expand Down
74 changes: 74 additions & 0 deletions crates/bashkit/tests/integration/threat_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,80 @@ use std::time::Duration;
mod resource_exhaustion {
use super::*;

/// TM-RES-INDIRECT: an indirect array target ends at the first `]`.
/// Trailing operator/nesting text is never re-expanded as code, so a
/// default operand cannot re-enter indirect expansion and exhaust the
/// stack (`r='a[0]:-${!r}]'`). Matches real bash: element-or-empty.
#[tokio::test]
async fn indirect_array_target_ends_at_first_close_bracket() {
// Trailing operator/nesting text is ignored, like real bash
// (verified: bash prints `out=[]`, `st=0` for every target below).
for target in [
"a[0]:-injected]",
"a[0]-injected]",
"a[0]:=injected]",
"a[0]+injected]",
"a[0]/x/injected]",
"a[0][1]",
"a[0]}${x:-injected}[0]",
] {
let mut bash = Bash::new();
let result = bash
.exec(&format!(
"r='{target}'\necho \"out=[${{!r}}]\"\necho \"st=$?\""
))
.await
.unwrap();
assert_eq!(result.stdout, "out=[]\nst=0\n", "target: {target}");
assert!(
result.stderr.is_empty(),
"target: {target}: {}",
result.stderr
);
}
// Set element resolves through the truncated target (bash: `1`).
let mut bash = Bash::new();
let result = bash
.exec("a=(1 2)\nr='a[0]:-injected]'\necho \"out=[${!r}]\"\necho \"st=$?\"")
.await
.unwrap();
assert_eq!(result.stdout, "out=[1]\nst=0\n");
assert!(result.stderr.is_empty(), "stderr: {}", result.stderr);
}

/// TM-RES-INDIRECT: self-referential default operand terminates.
/// Pre-fix this exact reproducer overflowed the stack (SIGABRT).
#[tokio::test]
async fn indirect_self_cycle_default_operand_terminates() {
// Exact finding reproducer: unset element + self-cycle in default.
// Real bash prints `out=[]` with status 0 and never crashes.
let mut bash = Bash::new();
let result = bash
.exec("unset a\nr='a[0]:-${!r}]'\necho \"out=[${!r}]\"\necho \"st=$?\"")
.await
.unwrap();
assert_eq!(result.stdout, "out=[]\nst=0\n");
assert!(result.stderr.is_empty(), "stderr: {}", result.stderr);
// Set element: the cycle operand is discarded, element wins.
let mut bash = Bash::new();
let result = bash
.exec("a=(1 2)\nr='a[0]:-${!r}]'\necho \"out=[${!r}]\"\necho \"st=$?\"")
.await
.unwrap();
assert_eq!(result.stdout, "out=[1]\nst=0\n");
assert!(result.stderr.is_empty(), "stderr: {}", result.stderr);
// Mutual cycle through two names terminates as well.
let mut bash = Bash::new();
let result = bash
.exec(
"unset a\nr='a[0]:-${!s}]'\ns='a[0]:-${!r}]'\necho \"out=[${!r}]\"\necho \"st=$?\"",
)
.await
.unwrap();
assert_eq!(result.stdout, "out=[]\nst=0\n");
assert!(result.stderr.is_empty(), "stderr: {}", result.stderr);
}

/// V1: Test that command limit prevents infinite execution
#[tokio::test]
async fn threat_infinite_commands_blocked() {
Expand Down
1 change: 1 addition & 0 deletions knowledge/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ runaway scripts without permanently breaking the session.
| TM-DOS-132 | Completion spec and key binding growth | `complete -W "$big" nameN` or `bind -x '"\C-xN": cmd'` in a loop keeps per-shell state growing | At most 1024 completion specs of at most 64 KiB option text each, and at most 1024 `bind` changes of at most 4 KiB each, per interpreter; past a cap the builtin fails with status 1. The state is built on first use, lives in the interpreter (never global, so tenants never share it) and is copied into subshells like other shell state. Regression: `completion_specs_are_bounded`, `binding_changes_are_capped` | **MITIGATED** |
| TM-DOS-101 | yq structured-data amplification | YAML aliases can expand exponentially during deserialization; deep YAML/JSON, document floods, jaq generators, and YAML re-serialization can consume stack, CPU, or memory beyond the source size | Reject YAML alias tokens with a bounded lexical pass before deserialization; VFS/stdin and aggregate input budgets; serde_yaml_ng recursion cap 128 plus Bashkit depth 100; 4096-document cap; shared jaq work/deadline/output limits; post-serialization stdout cap; YAML+JSON depth/document/input/output regressions plus `yq_fuzz` and arbitrary-input proptest | **MITIGATED** |
| TM-DOS-133 | Unbounded prompt expansion | `x='${x@P}'; echo "${x@P}"`, mutual variable cycles or cycles through command substitutions repeatedly create shallow parsers and recursively poll futures | Prompt reparses count cumulative runtime depth capped at `min(max_ast_depth, 8)` before recursive polling; descendants inherit depth. Each prompt charges the shared work and aggregate-input budget, checking cancellation and deadlines before decoding; child parsers share the request budget and fail closed on poison. Depth unwinds on returned errors and resets at host execution boundaries. Regressions: `prompt_resource_tests`, `prompt_checks_cancellation_before_decoding`, `prompt_checks_shared_deadline_before_decoding`, `prompt_error_unwinds_depth` | **MITIGATED** |
| TM-DOS-134 | Indirect array-target operand recursion | `r='a[0]:-${!r}]'; echo "${!r}"`: the bracket validator accepted trailing operator/nesting text, so desugar re-parsed `${a[0]:-${!r}]}` and the unset-element default re-entered indirect expansion without bound (stack overflow) | The resolved bracket target is truncated at the first `]` after `[` before validation (real bash resolves `a[0]` and ignores the rest), so trailing operands — including a nested `${!...}` — never reach the desugar re-parse; `$(`/backquote/process-substitution guards unchanged. Regressions: `indirect_array_target_ends_at_first_close_bracket`, `indirect_self_cycle_default_operand_terminates` | **MITIGATED** |
| TM-DOS-107 | Static-analysis command validation amplification | A large comment before thousands of commands makes a whole-script ordered-subsequence scan per command | Build one source-character position index, then validate each analyzed command-name character with a logarithmic position lookup instead of rescanning source | **MITIGATED** |
**TM-DOS-051** is historical. The custom parser was deleted with the `yaml`
helper; the replacement `yq` parser/evaluator boundary is tracked by TM-DOS-101.
Expand Down
Loading