Skip to content

Commit

Permalink
SQLite: Throw better exception message when people use BEGIN TRANSACT…
Browse files Browse the repository at this point in the history
…ION.

People mistakenly believed that we didn't support transactions!
  • Loading branch information
kentonv committed Oct 8, 2024
1 parent 9c848e0 commit 6bbea52
Show file tree
Hide file tree
Showing 4 changed files with 41 additions and 15 deletions.
10 changes: 8 additions & 2 deletions src/workerd/api/sql-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,14 @@ async function test(state) {
);

// Can't start transactions or savepoints.
assert.throws(() => sql.exec('BEGIN TRANSACTION'), /not authorized/);
assert.throws(() => sql.exec('SAVEPOINT foo'), /not authorized/);
assert.throws(
() => sql.exec('BEGIN TRANSACTION'),
/please use the state.storage.transaction\(\) or state.storage.transactionSync\(\) APIs/
);
assert.throws(
() => sql.exec('SAVEPOINT foo'),
/please use the state.storage.transaction\(\) or state.storage.transactionSync\(\) APIs/
);

// Virtual tables
// Only fts5 and fts5vocab modules are allowed
Expand Down
14 changes: 6 additions & 8 deletions src/workerd/api/sql.c++
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,12 @@ void SqlStorage::onError(kj::Maybe<int> sqliteErrorCode, kj::StringPtr message)
}

bool SqlStorage::allowTransactions() const {
if (IoContext::hasCurrent()) {
IoContext::current().logWarningOnce(
"To execute a transaction, please use the state.storage.transaction() API instead of the "
"SQL BEGIN TRANSACTION or SAVEPOINT statements. The JavaScript API is safer because it "
"will automatically roll back on exceptions, and because it interacts correctly with "
"Durable Objects' automatic atomic write coalescing.");
}
return false;
JSG_FAIL_REQUIRE(Error,
"To execute a transaction, please use the state.storage.transaction() or "
"state.storage.transactionSync() APIs instead of the SQL BEGIN TRANSACTION or SAVEPOINT "
"statements. The JavaScript API is safer because it will automatically roll back on "
"exceptions, and because it interacts correctly with Durable Objects' automatic atomic "
"write coalescing.");
}

jsg::JsValue SqlStorage::wrapSqlValue(jsg::Lock& js, SqlValue value) {
Expand Down
22 changes: 19 additions & 3 deletions src/workerd/util/sqlite.c++
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,18 @@ SqliteDatabase::StatementAndEffect SqliteDatabase::prepareSql(
sqlite3_stmt* result;
const char* tail;

SQLITE_CALL(sqlite3_prepare_v3(db, sqlCode.begin(), sqlCode.size(), prepFlags, &result, &tail));
auto prepareResult =
sqlite3_prepare_v3(db, sqlCode.begin(), sqlCode.size(), prepFlags, &result, &tail);
if (prepareResult == SQLITE_AUTH) {
KJ_IF_SOME(error, parseContext.authError) {
// Throw the tailored auth error.
kj::throwFatalException(kj::mv(error));
}
}
if (prepareResult != SQLITE_OK) {
SQLITE_CALL_FAILED("sqlite3_prepare_v3", prepareResult);
}

SQLITE_REQUIRE(result != nullptr, kj::none, "SQL code did not contain a statement.", sqlCode);
auto ownResult = ownSqlite(result);

Expand Down Expand Up @@ -1044,8 +1055,13 @@ void SqliteDatabase::setupSecurity(sqlite3* db) {
? SQLITE_OK
: SQLITE_DENY;
} catch (kj::Exception& e) {
// We'll crash if we throw to SQLite. Instead, log the error and deny.
KJ_LOG(ERROR, e);
// We'll crash if we throw to SQLite. Instead, shove the error into the parse context and
// report authorization denied. We'll pull it back out later.
KJ_IF_SOME(context, reinterpret_cast<SqliteDatabase*>(userdata)->currentParseContext) {
context.authError = kj::mv(e);
} else {
KJ_LOG(ERROR, e);
}
return SQLITE_DENY;
}
},
Expand Down
10 changes: 8 additions & 2 deletions src/workerd/util/sqlite.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,11 @@ class SqliteDatabase {

// Class which regulates a SQL query, especially to control how queries created in JavaScript
// application code are handled.
//
// Note that any of the methods that check if actions are allowed my throw an exception instead
// of returning false. If they do, this exception will pass through to the caller in place of
// a generic "not authorized" exception.
class Regulator {

public:
// Returns whether the given name (which may be a table, index, view, etc.) is allowed to be
// accessed. Typically, this is used to deny access to names containing special prefixes
Expand Down Expand Up @@ -340,7 +343,10 @@ class SqliteDatabase {
// What kind of state change does this statement cause, if any?
StateChange stateChange = NoChange();

// TODO(soon): We could also use this to generate better errors.
// If the parse fails because the authorizer rejects it, it may fill in `authError` to provide
// a more friendly error message. This error will be thrown by the overal query. Otherwise,
// a generic "not authorized" error is thrown.
kj::Maybe<kj::Exception> authError;
};
};

Expand Down

0 comments on commit 6bbea52

Please sign in to comment.