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 @@ -184,6 +184,7 @@ let bash = Bash::builder()
| Persistent history memory DoS (TM-DOS-094) | Unbounded command history in long-lived instances | `ExecutionLimits` caps history entries/bytes/output | FIXED |
| Multi-component glob amplification (TM-DOS-095) | `/*/*/*/*` multiplies the candidate set at each component | Reject patterns deeper than `max_path_depth`; cap live candidates at `max_file_count` | FIXED |
| Aggregate budget refresh (TM-DOS-096) | Nest/mix parsers, pipelines, traversal, runtimes, archives, and callbacks to restart local ceilings | One poisoned request-scoped `ExecutionBudget` meters aggregate work/input/live bytes without replacing subsystem caps | MITIGATED |
| Tilde expansion amplification (TM-DOS-134) | Large `HOME`/`PWD`/`OLDPWD` repeated after assignment colons | Borrowed directories, checked pre-allocation live-byte reservations, byte-proportional work/deadline checks, and leases retained through assignment/field consumption; failures abort expansion | MITIGATED |
| Contradictory execution-profile limits (TM-DOS-097) | Host config silently requests ineffective or impossible limits | Validate profile cross-field invariants before `BashBuilder` accepts it | MITIGATED |
| Suspended host-call retention (TM-DOS-098) | Script repeats event-backed calls or host never resumes one | Capacity-one channel; an independently driven execution future keeps the deadline armed while parked and drops the timed-out session without another host poll; handle drop aborts the driver | MITIGATED |
| `time` report amplification (TM-DOS-099) | Attacker-controlled `-f` format expands repeatedly or targets the VFS with `-o` | Incremental rendering is capped by the stderr limit before emission or file replacement | MITIGATED |
Expand Down
3 changes: 2 additions & 1 deletion crates/bashkit/src/interpreter/declare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1441,7 +1441,8 @@ impl Interpreter {
if let Some((kw, vw, kappend)) = split_keyed_word(word).filter(|_| !braces) {
let key_text = self.expand_array_word(&kw).await?;
// `[k]=~:~` tilde-expands like an assignment value.
let vw = self.tilde_assignment_value(&vw).into_owned();
// `[k]=~:~` tilde-expands like an assignment value.
let (vw, _tilde_leases) = self.tilde_assignment_value(&vw)?;
let val = self.expand_array_word(&vw).await?;
items.try_push(Item::Keyed(key_text, val, kappend))?;
continue;
Expand Down
204 changes: 158 additions & 46 deletions crates/bashkit/src/interpreter/expansion.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8444,7 +8444,7 @@ impl Interpreter {
}
match &assignment.value {
AssignmentValue::Scalar(word) => {
let word = self.tilde_assignment_value(word);
let (word, _tilde_leases) = self.tilde_assignment_value(word)?;
let value = self.expand_word(&word).await?;
if self.is_xtrace_enabled() {
// `+ v=x`, `+ a[1]='a b'`, `+ v+=y`
Expand Down
120 changes: 120 additions & 0 deletions crates/bashkit/tests/integration/execution_budget_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,32 @@ async fn indexed_array_pending_values_share_live_byte_limit() {
}
}

#[tokio::test]
async fn tilde_expansion_repeated_directories_respect_live_budget() {
// Safe reproducer: at most 24 KiB of expansion, admitted by retained limits.
for (name, tilde) in [("HOME", "~"), ("PWD", "~+"), ("OLDPWD", "~-")] {
for (command, suffix) in [
("x=a", ""),
("export x=a", ""),
("echo x=a", ""),
("x[0]=a", ""),
("declare -a x=([0]=a", ")"),
("echo x=a", "\"\""),
] {
let limits = ExecutionLimits::new().max_live_intermediate_bytes(8_192);
let mut bash = Bash::builder().limits(limits).build();
let script = format!(
"export {name}='{}'; {command}{}{suffix}",
"d".repeat(1_024),
format!(":{tilde}").repeat(24)
);
assert_budget_exhausted(bash.exec(&script).await);
let recovered = bash.exec("echo recovered").await.unwrap();
assert_eq!(recovered.stdout, "recovered\n");
}
}
}

#[tokio::test]
async fn indexed_array_pending_keys_share_live_byte_limit() {
let mut bash = Bash::builder()
Expand Down Expand Up @@ -593,6 +619,66 @@ async fn indexed_array_budget_preserves_expansion_order() {
assert_eq!(result.stderr.as_bytes(), expected.stderr);
}

#[tokio::test]
async fn tilde_expansion_charges_copied_bytes_as_work() {
let limits = ExecutionLimits::new().max_work_units(5_000);
let mut bash = Bash::builder().limits(limits).build();
bash.exec(&format!("HOME='{}'", "d".repeat(1_024)))
.await
.unwrap();
assert_budget_exhausted(bash.exec(&format!("x=a{}", ":~".repeat(24))).await);
}

#[tokio::test]
async fn tilde_expansion_single_directory_and_operands_respect_live_budget() {
for command in ["x=~", "echo ~", "echo ${unset:-~}", "echo ${unset:=~}"] {
let limits = ExecutionLimits::new().max_live_intermediate_bytes(512);
let mut bash = Bash::builder().limits(limits).build();
bash.exec(&format!("HOME='{}'", "d".repeat(1_024)))
.await
.unwrap();
assert_budget_exhausted(bash.exec(command).await);
}
}

#[tokio::test]
async fn tilde_expansion_retains_sibling_assignment_leases() {
let limits = ExecutionLimits::new().max_live_intermediate_bytes(3_072);
let mut bash = Bash::builder().limits(limits).build();
bash.exec(&format!("HOME='{}'", "d".repeat(1_024)))
.await
.unwrap();
assert_budget_exhausted(bash.exec("x=~/\"\":~/\"\":~/").await);
}

#[tokio::test]
async fn tilde_expansion_preserves_bash_semantics() {
let script = r#"HOME='/home/test dir'; PWD=/pwd; OLDPWD=/old
x=~:~+/bin:~-/lib:~missing
printf '%s\n' "$x" ~ ~missing
printf '%s\n' x=~:~+:'~'
x='~:~'; printf '%s\n' "$x" '~' \~ '~'$HOME
unset value; printf '%s\n' "${value:-~}" "${value:-'~'}"
x=~/"":~; printf '%s\n' "$x"
"#;
let expected = std::process::Command::new("bash")
.env("HOME", "/home/test dir")
.arg("--noprofile")
.arg("--norc")
.arg("-c")
.arg(script)
.output()
.unwrap();
assert!(expected.status.success());
let mut bash = Bash::builder()
.limits(ExecutionLimits::new().max_live_intermediate_bytes(8_192))
.build();
let result = bash.exec(script).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.as_bytes(), expected.stdout);
assert_eq!(result.stderr.as_bytes(), expected.stderr);
}

#[tokio::test]
async fn indexed_array_empty_pending_items_still_charge_container_storage() {
let mut bash = Bash::builder()
Expand Down Expand Up @@ -632,3 +718,37 @@ async fn indexed_array_substitutions_release_consumed_output() {
.unwrap();
assert_eq!(result.stdout, "3072\n");
}

#[tokio::test]
async fn tilde_expansion_quotes_do_not_resolve_large_directories() {
let limits = ExecutionLimits::new().max_live_intermediate_bytes(512);
let mut bash = Bash::builder().limits(limits).build();
bash.exec(&format!("HOME='{}'", "d".repeat(1_024)))
.await
.unwrap();
let literal = format!("a{}", ":~".repeat(24));
let actual = bash
.exec(&format!("x='{literal}'; printf '%s' \"$x\""))
.await
.unwrap();
assert_eq!(actual.stdout, literal);
}

#[tokio::test]
async fn tilde_expansion_assignment_copy_boundary_and_release() {
for (bytes, succeeds) in [(15, false), (16, true)] {
let limits = ExecutionLimits::new().max_live_intermediate_bytes(bytes);
let mut bash = Bash::builder().limits(limits).build();
bash.exec("HOME=12345678").await.unwrap();
let result = bash.exec("x=~; y=~").await;
if succeeds {
assert_eq!(result.unwrap().exit_code, 0);
// Declaration leases release: a trivial follow-up still fits the
// same budget (value assertions live in the semantics test,
// which runs with a generous budget).
assert_eq!(bash.exec("echo done").await.unwrap().stdout, "done\n");
} else {
assert_budget_exhausted(result);
}
}
}
6 changes: 6 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ execution work and deadline budget; file contents are admitted against aggregate
input and live intermediate limits and scanned one file at a time. `-q` stops
at the first match, before further traversal.

Tilde expansion (`~`, `~+`, `~-`, including repeated assignment prefixes after
`:`) checks `ExecutionLimits::max_live_intermediate_bytes` before growing its
output and charges copied bytes to `max_work_units`. Large directory values
cannot bypass these limits by expanding before variable insertion. A refused
expansion returns a resource-limit error; the next host execution receives a
fresh execution budget.
Syntax-error reports are limited to 1 KiB during construction, including an
explicit `[diagnostic truncated]` marker when text is omitted. This prevents
multiline unexpected tokens and long `bash -c` shell names from multiplying
Expand Down
9 changes: 9 additions & 0 deletions knowledge/foundations/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ compression paths use these builders because their expansion ratios and nested
buffers make post-hoc leasing unsafe. Atomic compare/exchange admission keeps
concurrent descendants from wrapping or temporarily exceeding the shared cap.

Tilde expansion uses the same owning string builder before each append; it
borrows `HOME`/`PWD`/`OLDPWD` instead of cloning the directory on each prefix.
Scan and copy work polls the shared deadline and cancellation flag. Assignment
preprocessing retains leases for expanded literal buffers and their word copies
through consumption; mixed-word segments retain leases through field splitting.
The synchronous operand pipeline records a refused reservation in the existing
sticky resource-error slot, checked before the completed word is returned.
Retained-variable admission remains a separate check after expansion.

### `set -e` contexts

`errexit_active()` is `set -e` plus `condition_sequence_depth == 0`. The
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 @@ -300,6 +300,7 @@ runaway scripts without permanently breaking the session.
| TM-DOS-130 | Array and arithmetic report growth | `$(( $(( ... )) ))` nested inside arithmetic, thousands of `${a[-9]}` reads in one command, `${!r}` with a huge invalid target | Nested `$((` in arithmetic retains its 32-occurrence admission cap and shares the arithmetic depth/fuel budget with dollar subscripts (`expression recursion level exceeded`); queued `bad array subscript` warnings stop at 64 KiB per command; an invalid indirect target is echoed truncated to 256 chars. Regression: `array_and_arith_reports_are_bounded` | **MITIGATED** |
| TM-DOS-131 | History file growth and reads | `history -a` in a loop, `HISTSIZE` huge, a `bash -i` child reading a giant `$HISTFILE`, `history -r` on a multi-GB file | The history list stays under `max_history_entries`/`max_history_bytes` whatever `$HISTSIZE` says (TM-DOS-094 caps apply to every recorded line); `$HISTFILE` reads are refused past `max_input_bytes`; files are read and written through the VFS only, so its size and write limits apply; a `bash -i` child keeps its own list and the parent's comes back after it. Regression: `history_lines_stay_bounded`, `histfile_read_is_capped` | **MITIGATED** |
| 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-134 | Repeated tilde expansion amplification | A large `HOME`, `PWD` or `OLDPWD` repeated through assignment `:~`, `:~+` or `:~-` prefixes builds host strings before retained-variable limits run | Shared tilde expansion borrows directory values, charges scan/copy work (including deadline and cancellation checks), and uses `BudgetedString` to admit checked capacity growth before allocation. Preprocessed assignment literals keep their leases and reserve their word copies until assignment consumption; mixed-word segments keep leases through field splitting. Synchronous operand failures use the sticky resource-error slot and abort word expansion. Regression tests: `execution_budget_tests::tilde_expansion_*` use bounded 1 KiB directory values, small live/work budgets, session recovery and a real-Bash oracle | **MITIGATED** |
| TM-DOS-136 | Syntax diagnostic amplification | A multiline unexpected token combined with a long attacker-controlled `bash -c` ARG0 repeats the shell name for every token line, allocating quadratically before stderr capture | Unexpected-token messages and complete syntax reports use a fixed-capacity UTF-8 writer capped at 1 KiB before any append, stop formatting on overflow, and include `[diagnostic truncated]`; short reports preserve Bash semantics. Regressions: `error::tests::syntax_report_*`, `parser_bounds_unexpected_token_before_copying`, and `partial_parse_tests::{child_syntax_report_is_bounded, small_child_syntax_reports_match_bash}` | **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** |
Expand Down
Loading