Skip to content
Closed
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
6 changes: 6 additions & 0 deletions crates/bashkit/src/builtins/sqlite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,12 @@ fn push_stdout_bounded(
}

fn check_sql_policy(sql: &str, limits: &SqliteLimits) -> std::result::Result<(), String> {
if parser::is_recursive_cte(sql) {
return Err(
"recursive CTEs are not supported in the bashkit sandbox; query work cannot be bounded"
.to_string(),
);
}
match parser::leading_keyword(sql).as_deref() {
Some("ATTACH") | Some("DETACH") => {
return Err("ATTACH/DETACH is not supported in the bashkit sandbox; \
Expand Down
26 changes: 26 additions & 0 deletions crates/bashkit/src/builtins/sqlite/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,32 @@ pub(super) fn leading_keyword(sql: &str) -> Option<String> {
Some(s[..end].to_ascii_uppercase())
}

/// Return whether `sql` starts a recursive common-table expression.
///
/// Recursive CTE execution is denied until turso exposes an in-flight progress
/// callback: checking limits only between `step()` calls cannot bound work done
/// inside one step.
pub(super) fn is_recursive_cte(sql: &str) -> bool {
let s = strip_leading_noise(sql);
let Some(rest) = strip_keyword(s, "WITH") else {
return false;
};
let rest = strip_leading_noise(rest);
strip_keyword(rest, "RECURSIVE").is_some()
}

fn strip_keyword<'a>(sql: &'a str, keyword: &str) -> Option<&'a str> {
let (candidate, rest) = sql.as_bytes().split_at_checked(keyword.len())?;
if !candidate.eq_ignore_ascii_case(keyword.as_bytes()) {
return None;
}
let next = rest.first();
if next.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_') {
return None;
}
Some(std::str::from_utf8(rest).expect("keyword ends on an ASCII boundary"))
}

/// Return the PRAGMA name (lowercased ASCII) when `sql` is a PRAGMA
/// statement, else `None`. The name is the identifier following `PRAGMA `,
/// before any `=`, `(`, or whitespace.
Expand Down
14 changes: 6 additions & 8 deletions crates/bashkit/tests/integration/sqlite_differential_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,20 +290,18 @@ async fn join_inner() {
}

// ---------------------------------------------------------------------------
// Recursive CTEs
// Non-recursive CTEs
//
// Turso rejected `WITH RECURSIVE` up to 0.8.0-pre.2 ("Parse error: Recursive
// CTEs are not yet supported"), so this used to be a documented divergence.
// 0.8.0-pre.3 closed the gap, so it is now a plain parity assertion.
// Recursive CTEs are denied by sandbox policy because turso does not expose a
// progress callback that can interrupt work performed inside one step.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn recursive_cte_matches_host() {
async fn non_recursive_cte_matches_host() {
assert_matches(
&[],
"WITH RECURSIVE r(n) AS ( \
SELECT 1 UNION ALL SELECT n + 1 FROM r WHERE n < 5 \
) SELECT n FROM r;",
"WITH values_(n) AS (VALUES (1), (2), (3), (4), (5)) \
SELECT n FROM values_;",
)
.await;
}
20 changes: 20 additions & 0 deletions crates/bashkit/tests/integration/sqlite_security_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
//! | TM-SQL-008 | Recursive `.read` does not unbounded-recurse |
//! | TM-SQL-009 | ATTACH/DETACH blocked by policy |
//! | TM-SQL-010 | PRAGMA deny list blocks resource/FS knobs |
//! | TM-SQL-014 | Recursive CTEs cannot bypass cooperative query limits |

#![cfg(feature = "sqlite")]

Expand Down Expand Up @@ -121,6 +122,25 @@ async fn tm_sql_005_oversize_db_file_rejected() {
);
}

#[tokio::test]
async fn tm_sql_014_recursive_ctes_are_rejected() {
let mut bash = make_bash_default();
let r = bash
.exec(
r#"sqlite :memory: 'WITH /* policy noise */ RECURSIVE r(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM r WHERE n < 5
) SELECT n FROM r;'"#,
)
.await
.unwrap();
assert_eq!(r.exit_code, 1);
assert!(
r.stderr.contains("recursive CTEs are not supported"),
"stderr={:?}",
r.stderr
);
}

#[tokio::test]
async fn tm_sql_006_null_bytes_in_text_safely_round_trip() {
// Inserting binary including embedded NUL via X'..' literal must round-
Expand Down
7 changes: 6 additions & 1 deletion knowledge/runtimes/sqlite-builtin.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ leading SQL keyword via the parser's lightweight tokeniser
filesystem rather than the configured `MemoryIO`/`BashkitVfsIO`, a
sandbox escape. Plain `VACUUM` is denied for symmetry; there is no
sandbox-safe way to express it today.
- `WITH RECURSIVE` is unconditionally rejected. Turso does not expose a
progress-handler callback, so a recursive program can perform unbounded work
inside one `Statement::step()` call before Bashkit can check its deadline or
execution budget. Ordinary, non-recursive CTEs remain supported.
- `PRAGMA <name>` is checked against `SqliteLimits::pragma_deny`
(case-insensitive, schema-prefix-aware so `PRAGMA main.cache_size`
matches). Defaults block resource/FS-shaped knobs: `cache_size`,
Expand All @@ -210,7 +214,7 @@ rows → `[]\n`), `markdown`. Empty column list → empty string; empty row set
| TM-SQL-003 | DoS via large SQL input | `SqliteLimits::max_script_bytes` (4 MiB default) |
| TM-SQL-004 | DoS via huge result set | `SqliteLimits::max_rows_per_query` (1M default), checked before materialising each row |
| TM-SQL-005 | DoS via huge DB file | `SqliteLimits::max_db_bytes` (256 MiB default) at load time and while growing DBs |
| TM-SQL-005a | DoS via wall-clock burn (regex-style queries, CTEs) | `SqliteLimits::max_duration` enforced via per-step deadline + `Statement::interrupt()` |
| TM-SQL-005a | DoS via wall-clock burn (regex-style queries) | `SqliteLimits::max_duration` enforced via per-step deadline + `Statement::interrupt()` |
| TM-SQL-005b | DoS via statement-flood (millions of `;`) | `SqliteLimits::max_statements` checked after splitting |
| TM-SQL-006 | Binary corruption / truncation in BLOB round-trip | Backed by `Vec<u8>`; tested via `tm_sql_006` |
| TM-SQL-007 | CSV escape failure with separator-bearing blobs | Per-RFC-4180 quoting; tested via `tm_sql_007` |
Expand All @@ -220,6 +224,7 @@ rows → `[]\n`), `markdown`. Empty column list → empty string; empty row set
| TM-SQL-011 | Information leakage via host-side error strings | `sanitize()` strips ` at /…:N:M` annotations from turso errors |
| TM-SQL-012 | Sandbox escape via `VACUUM INTO` writing host files | Policy rejects `VACUUM` (with/without `INTO`) at the keyword sniffer; tested via `vacuum_into_blocked`/`vacuum_plain_blocked`/`vacuum_blocked_with_leading_comment` |
| TM-SQL-013 | DoS via `.dump` cumulative output bypass | `.dump` previously built the full string before `max_output_bytes` was applied; `bounded_append()` enforces the cap after each schema/row chunk with the remaining budget passed from `run_statements`; `THREAT[TM-DOS-091]`; tested via `dump_respects_output_cap` and `dump_output_cap_enforced_across_multiple_tables` |
| TM-SQL-014 | DoS via recursive CTE work inside one engine step | Policy rejects `WITH RECURSIVE` until turso exposes an in-flight progress callback; comment- and case-aware; tested via `tm_sql_014_recursive_ctes_are_rejected` |

## Test Plan

Expand Down
Loading