diff --git a/crates/bashkit/src/builtins/sqlite/mod.rs b/crates/bashkit/src/builtins/sqlite/mod.rs index cf92c2605..2e247a726 100644 --- a/crates/bashkit/src/builtins/sqlite/mod.rs +++ b/crates/bashkit/src/builtins/sqlite/mod.rs @@ -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; \ diff --git a/crates/bashkit/src/builtins/sqlite/parser.rs b/crates/bashkit/src/builtins/sqlite/parser.rs index 556458d3d..8bf8a4695 100644 --- a/crates/bashkit/src/builtins/sqlite/parser.rs +++ b/crates/bashkit/src/builtins/sqlite/parser.rs @@ -189,6 +189,32 @@ pub(super) fn leading_keyword(sql: &str) -> Option { 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. diff --git a/crates/bashkit/tests/integration/sqlite_differential_tests.rs b/crates/bashkit/tests/integration/sqlite_differential_tests.rs index c6de07119..74b30f45d 100644 --- a/crates/bashkit/tests/integration/sqlite_differential_tests.rs +++ b/crates/bashkit/tests/integration/sqlite_differential_tests.rs @@ -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; } diff --git a/crates/bashkit/tests/integration/sqlite_security_tests.rs b/crates/bashkit/tests/integration/sqlite_security_tests.rs index c16a4bcae..42927f4ba 100644 --- a/crates/bashkit/tests/integration/sqlite_security_tests.rs +++ b/crates/bashkit/tests/integration/sqlite_security_tests.rs @@ -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")] @@ -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- diff --git a/knowledge/runtimes/sqlite-builtin.md b/knowledge/runtimes/sqlite-builtin.md index 56c9d7db9..02250c2e2 100644 --- a/knowledge/runtimes/sqlite-builtin.md +++ b/knowledge/runtimes/sqlite-builtin.md @@ -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 ` 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`, @@ -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`; tested via `tm_sql_006` | | TM-SQL-007 | CSV escape failure with separator-bearing blobs | Per-RFC-4180 quoting; tested via `tm_sql_007` | @@ -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