Skip to content

Commit 8e86fee

Browse files
TJKouryclaude
andcommitted
engine: sandboxed public query — flatsql_query_sandboxed (SDN gateway loop G.5)
One read-only SELECT for UNTRUSTED callers, enforced in-engine: - sqlite3_set_authorizer during prepare: only SELECT / functions / recursive CTEs / reads of record vtabs + shadow tables + unified views; PRAGMA, ATTACH/DETACH, all DDL/DML (incl. temp), TRANSACTION/SAVEPOINT and reads of control tables or sqlite_* reserved names are denied. CTE/subquery aliases are recognized against a schema enumeration that FAILS CLOSED. - single-statement (non-whitespace prepare tail rejected), sqlite3_stmt_readonly + result-column checks. - statement timeout via sqlite3_progress_handler (steady-clock deadline; SQLITE_OMIT_PROGRESS_CALLBACK removed from the wasm builds — with no handler installed the VDBE overhead is a compare per jump op). - row/byte caps enforced in the step loop: oversized results REJECT, never truncate. - two output modes through the response-artifact readout: record stream (all-BLOB cells -> aligned size-prefixed frames, byte-parity with queryRawFlatBufferStream) and JSON rows (bare array, column names verbatim from SQLite — schema-exact key capitalization by construction; blobs base64, NaN/Inf -> null). - bypasses statement/query/raw-stream caches entirely; no-throw on both builds (errors latch with a stable 'sandbox: <code>: ...' prefix). JS wrappers querySandboxed() on both hosts (emscripten index.js + standalone.js) with .d.ts; jest suite test/sandbox-query.test.ts covers the injection/abuse matrix on both hosts (150/150 green). v1.2.0. Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent a49f85d commit 8e86fee

19 files changed

Lines changed: 1050 additions & 6 deletions

cpp/CMakeLists.txt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten" OR EMSCRIPTEN)
141141
SQLITE_OMIT_WAL=1
142142
SQLITE_OMIT_DEPRECATED=1
143143
SQLITE_OMIT_SHARED_CACHE=1
144-
SQLITE_OMIT_PROGRESS_CALLBACK=1
145144
SQLITE_DEFAULT_MEMSTATUS=0
146145
SQLITE_DQS=0
147146
SQLITE_ENABLE_RTREE=1
@@ -178,6 +177,7 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten" OR EMSCRIPTEN)
178177
\"_flatsql_result_column_name\", \"_flatsql_result_cell_type\", \
179178
\"_flatsql_result_cell_number\", \"_flatsql_result_cell_string\", \
180179
\"_flatsql_result_cell_blob\", \"_flatsql_result_cell_blob_size\", \
180+
\"_flatsql_query_sandboxed\", \
181181
\"_flatsql_query_raw_flatbuffer_stream\", \"_flatsql_response_artifact_data\", \
182182
\"_flatsql_response_artifact_size\", \"_flatsql_response_artifact_row_count\", \
183183
\"_flatsql_response_artifact_column_count\", \"_flatsql_response_artifact_cache_hit\", \
@@ -239,7 +239,6 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten" OR EMSCRIPTEN)
239239
SQLITE_OMIT_WAL=1
240240
SQLITE_OMIT_DEPRECATED=1
241241
SQLITE_OMIT_SHARED_CACHE=1
242-
SQLITE_OMIT_PROGRESS_CALLBACK=1
243242
SQLITE_DEFAULT_MEMSTATUS=0
244243
SQLITE_DQS=0
245244
SQLITE_ENABLE_RTREE=1
@@ -274,6 +273,7 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten" OR EMSCRIPTEN)
274273
\"_flatsql_result_column_name\", \"_flatsql_result_cell_type\", \
275274
\"_flatsql_result_cell_number\", \"_flatsql_result_cell_string\", \
276275
\"_flatsql_result_cell_blob\", \"_flatsql_result_cell_blob_size\", \
276+
\"_flatsql_query_sandboxed\", \
277277
\"_flatsql_query_raw_flatbuffer_stream\", \"_flatsql_response_artifact_data\", \
278278
\"_flatsql_response_artifact_size\", \"_flatsql_response_artifact_row_count\", \
279279
\"_flatsql_response_artifact_column_count\", \"_flatsql_response_artifact_cache_hit\", \
@@ -345,7 +345,6 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten" OR EMSCRIPTEN)
345345
SQLITE_OMIT_WAL=1
346346
SQLITE_OMIT_DEPRECATED=1
347347
SQLITE_OMIT_SHARED_CACHE=1
348-
SQLITE_OMIT_PROGRESS_CALLBACK=1
349348
SQLITE_DEFAULT_MEMSTATUS=0
350349
SQLITE_DQS=0
351350
SQLITE_ENABLE_RTREE=1

cpp/include/flatsql/database.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,22 @@ class FlatSQLDatabase {
257257

258258
RawStreamCacheStats getRawStreamCacheStats() const;
259259

260+
// ==================== Sandboxed public query (gateway loop G.5) ====================
261+
// Execute UNTRUSTED SQL through SQLiteEngine::executeSandboxed: one
262+
// read-only SELECT, authorizer-restricted to this database's record
263+
// tables / source shadow tables / unified views (control tables created
264+
// through plain SQL DDL are NOT readable), with a statement timeout and
265+
// row/byte caps. Bypasses every cache (no pollution, no invalidation) —
266+
// sandboxed executions are structurally read-only. Never throws; on
267+
// failure *errorMessage carries "sandbox: <code>: ..." for sandbox
268+
// rejections or "SQL error: ..." for plain SQL errors.
269+
bool querySandboxed(const std::string& sql,
270+
const std::vector<Value>& params,
271+
SQLiteEngine::SandboxMode mode,
272+
const SQLiteEngine::SandboxLimits& limits,
273+
SQLiteEngine::SandboxOutput* out,
274+
std::string* errorMessage) noexcept;
275+
260276
// Execute and count without building QueryResult (for benchmarking)
261277
size_t queryCount(const std::string& sql, const std::vector<Value>& params = {});
262278

cpp/include/flatsql/sqlite_engine.h

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,58 @@ class SQLiteEngine {
113113
*/
114114
bool validateSQL(const std::string& sql, int* paramCountOut, std::string* errOut) noexcept;
115115

116+
// ==================== Sandboxed public query (gateway loop G.5) ====================
117+
// A single-statement, read-only, resource-capped execution path for
118+
// UNTRUSTED SQL (the public /api/v1/query surface). Never throws — every
119+
// rejection lands in *errOut with a stable "sandbox: <code>: ..." prefix
120+
// so hosts can map violations to typed errors. Defense layers:
121+
// 1. sqlite3_set_authorizer during prepare: only SQLITE_SELECT /
122+
// SQLITE_FUNCTION / SQLITE_RECURSIVE / SQLITE_READ-on-allowlisted
123+
// tables are permitted; PRAGMA, ATTACH/DETACH, every DDL/DML verb
124+
// (incl. temp objects), TRANSACTION/SAVEPOINT and reads outside
125+
// `allowedTables` are denied (prepare fails).
126+
// 2. single-statement: any non-whitespace prepare tail is rejected.
127+
// 3. sqlite3_stmt_readonly must be true and the statement must return
128+
// result columns (SELECT-shaped).
129+
// 4. sqlite3_progress_handler timeout (steady-clock deadline) —
130+
// runaway statements abort with SQLITE_INTERRUPT.
131+
// 5. row / byte caps enforced inside the step loop (reject, not
132+
// truncate).
133+
// The sandbox never touches the statement cache or the query/raw-stream
134+
// caches, and never invalidates anything (it is structurally read-only).
135+
136+
enum class SandboxMode {
137+
RecordStream, // all cells BLOB -> aligned [u32le size][bytes] frames
138+
JsonRows // bare JSON array of {"<column>": value} objects
139+
};
140+
141+
struct SandboxLimits {
142+
uint64_t maxRows = 0; // 0 = unlimited
143+
uint64_t maxBytes = 0; // 0 = unlimited (output payload bytes)
144+
uint32_t timeoutMs = 0; // 0 = no deadline
145+
};
146+
147+
struct SandboxOutput {
148+
std::vector<uint8_t> payload; // stream frames or UTF-8 JSON array
149+
size_t rowCount = 0;
150+
size_t columnCount = 0;
151+
};
152+
153+
/**
154+
* Execute untrusted SQL under the sandbox contract above. Never throws.
155+
*
156+
* @param allowedTables table/view names SQLITE_READ may touch
157+
* @return true on success; false with *errOut set ("sandbox: <code>: ..."
158+
* for sandbox rejections, "SQL error: ..." for plain SQL errors)
159+
*/
160+
bool executeSandboxed(const std::string& sql,
161+
const std::vector<Value>& params,
162+
const std::unordered_set<std::string>& allowedTables,
163+
SandboxMode mode,
164+
const SandboxLimits& limits,
165+
SandboxOutput* out,
166+
std::string* errOut) noexcept;
167+
116168
/**
117169
* Execute a SQL query and return results.
118170
*

cpp/src/database.cpp

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include <limits>
66
#include <mutex>
77
#include <stdexcept>
8+
#include <unordered_set>
89

910
#ifdef FLATSQL_HAVE_OPENSSL
1011
#include <openssl/hmac.h>
@@ -909,6 +910,41 @@ bool FlatSQLDatabase::queryRawFlatBufferStream(const std::string& sql,
909910
return true;
910911
}
911912

913+
bool FlatSQLDatabase::querySandboxed(const std::string& sql,
914+
const std::vector<Value>& params,
915+
SQLiteEngine::SandboxMode mode,
916+
const SQLiteEngine::SandboxLimits& limits,
917+
SQLiteEngine::SandboxOutput* out,
918+
std::string* errorMessage) noexcept {
919+
try {
920+
std::unique_lock lock(*accessMutex_);
921+
if (!sqliteInitialized_) {
922+
initializeSQLiteEngine();
923+
}
924+
925+
// The public query surface: record base tables (schema names),
926+
// per-source shadow tables ("OMM@celestrak-gp"), and the unified
927+
// views (which reuse the base table name). Control tables created
928+
// through plain SQL DDL are deliberately absent — the authorizer
929+
// denies reading them.
930+
std::unordered_set<std::string> allowed;
931+
for (const auto& tableDef : schema_.tables) {
932+
allowed.insert(tableDef.name);
933+
}
934+
for (const auto& entry : tables_) {
935+
allowed.insert(entry.first);
936+
}
937+
938+
return sqliteEngine_->executeSandboxed(sql, params, allowed, mode, limits, out,
939+
errorMessage);
940+
} catch (...) {
941+
if (errorMessage) {
942+
try { *errorMessage = "sandbox: internal: sandboxed execution failed"; } catch (...) {}
943+
}
944+
return false;
945+
}
946+
}
947+
912948
void FlatSQLDatabase::storeRawStreamResultUnlocked(const std::string& key,
913949
const RawStreamResult& result) {
914950
const size_t streamBytes = result.stream ? result.stream->size() : 0;

cpp/src/flatsql_capi.cpp

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1355,6 +1355,71 @@ int flatsql_query_raw_flatbuffer_stream(
13551355
}
13561356
}
13571357

1358+
// Sandboxed public query (gateway loop G.5): one read-only SELECT under an
1359+
// authorizer (record tables / shadow tables / unified views only; no PRAGMA,
1360+
// no ATTACH, no DDL/DML/temp writes), single-statement enforced, statement
1361+
// timeout via the progress handler, row/byte caps enforced in the step loop.
1362+
// mode 0 = record stream (all cells BLOB -> aligned [u32le size][bytes]
1363+
// frames), mode 1 = bare JSON array of {"<column>": value} objects with
1364+
// column names verbatim from SQLite (schema-exact capitalization). The
1365+
// result is read through the response-artifact exports (data/size/row_count/
1366+
// column_count; cache_hit always 0 — sandboxed executions bypass every
1367+
// cache). Limits <= 0 mean "unlimited"; timeoutMs <= 0 means no deadline.
1368+
// Returns 1 on success, 0 with flatsql_get_error() latched ("sandbox:
1369+
// <code>: ..." for sandbox rejections).
1370+
EMSCRIPTEN_KEEPALIVE
1371+
int flatsql_query_sandboxed(
1372+
void* handle,
1373+
const char* sql,
1374+
const uint8_t* paramData,
1375+
size_t paramLength,
1376+
int paramCount,
1377+
int mode,
1378+
double maxRows,
1379+
double maxBytes,
1380+
double timeoutMs
1381+
) {
1382+
auto* db = static_cast<FlatSQLDatabase*>(handle);
1383+
const std::string sqlStr = sql ? sql : "";
1384+
1385+
std::vector<Value> params;
1386+
std::string errorMessage;
1387+
if (!decodeParamsNoThrow(paramData, paramLength, paramCount, &params, &errorMessage)) {
1388+
clearResponseArtifact();
1389+
g_lastError = errorMessage;
1390+
return 0;
1391+
}
1392+
if (mode != 0 && mode != 1) {
1393+
clearResponseArtifact();
1394+
g_lastError = "sandbox: mode: unknown result mode (0 = record stream, 1 = json rows)";
1395+
return 0;
1396+
}
1397+
1398+
SQLiteEngine::SandboxLimits limits;
1399+
if (maxRows > 0) limits.maxRows = static_cast<uint64_t>(maxRows);
1400+
if (maxBytes > 0) limits.maxBytes = static_cast<uint64_t>(maxBytes);
1401+
if (timeoutMs > 0) limits.timeoutMs = static_cast<uint32_t>(timeoutMs);
1402+
1403+
SQLiteEngine::SandboxOutput output;
1404+
if (!db->querySandboxed(sqlStr, params,
1405+
mode == 0 ? SQLiteEngine::SandboxMode::RecordStream
1406+
: SQLiteEngine::SandboxMode::JsonRows,
1407+
limits, &output, &errorMessage)) {
1408+
clearResponseArtifact();
1409+
g_lastError = errorMessage;
1410+
return 0;
1411+
}
1412+
1413+
g_responseArtifactStream =
1414+
std::make_shared<const std::vector<uint8_t>>(std::move(output.payload));
1415+
g_responseArtifactRowCount = output.rowCount;
1416+
g_responseArtifactColumnCount = output.columnCount;
1417+
g_responseArtifactCacheHit = 0;
1418+
1419+
g_lastError.clear();
1420+
return 1;
1421+
}
1422+
13581423
EMSCRIPTEN_KEEPALIVE
13591424
const uint8_t* flatsql_response_artifact_data() {
13601425
return g_responseArtifactStream ? g_responseArtifactStream->data() : nullptr;

0 commit comments

Comments
 (0)