Skip to content

Commit 2f5a524

Browse files
adds set_authorizer/2 (fixes #344) (#345)
Adds `Exqlite.Sqlite3.set_authorizer/2` which registers a deny-list authorizer via `sqlite3_set_authorizer()`. The caller passes a list of action atoms to block (e.g. `[:attach, :detach]`), and a static C callback denies those action codes during `sqlite3_prepare()`. Pass an empty list to clear the authorizer. ```elixir :ok = Exqlite.Sqlite3.set_authorizer(conn, [:attach, :detach]) {:error, "not authorized"} = Exqlite.Sqlite3.execute(conn, "ATTACH DATABASE 'other.db' AS x") :ok = Exqlite.Sqlite3.set_authorizer(conn, []) # clear ``` A full Erlang callback isn't practical here since the authorizer is called synchronously during `sqlite3_prepare()` and must return immediately. The deny-list approach avoids this as config is stored in the connection struct as a flat `int[64]` array indexed by action code and the callback is a single bounds check and array lookup with no allocation. Implementation notes: - Follows the same pattern as `set_update_hook` - All 31 SQLite action codes (1-33) are mapped to snake_case atoms - The deny array is sized to 64 for headroom beyond the current max action code (33) - Invalid atoms in the deny list raise `ArgumentError` without modifying the existing authorizer - 7 tests: deny single, deny multiple, allow non-denied, clear, high action code (savepoint), invalid atom preserves existing authorizer, pure invalid raises Since this feature is likely to be used in security-sensitive contexts I'd appreciate a careful review of the C code. I'm not the most experienced C developer and this touches the security boundary of the library. PR description written mostly by AI so if it sounds like slop it's because it is
1 parent 552eb8b commit 2f5a524

4 files changed

Lines changed: 377 additions & 0 deletions

File tree

c_src/sqlite3_nif.c

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,17 @@ static sqlite3_mem_methods default_alloc_methods = {0};
4848
ErlNifPid* log_hook_pid = NULL;
4949
ErlNifMutex* log_hook_mutex = NULL;
5050

51+
// Denied authorizer action codes. Sized to 64 for margin — highest
52+
// currently defined SQLite action code is SQLITE_RECURSIVE (33).
53+
#define AUTHORIZER_DENY_SIZE 64
54+
5155
typedef struct connection
5256
{
5357
sqlite3* db;
5458
ErlNifMutex* mutex;
5559
ErlNifMutex* interrupt_mutex;
5660
ErlNifPid update_hook_pid;
61+
int authorizer_deny[AUTHORIZER_DENY_SIZE];
5762
} connection_t;
5863

5964
typedef struct statement
@@ -340,6 +345,7 @@ exqlite_open(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
340345
conn->db = db;
341346
conn->mutex = mutex;
342347
conn->interrupt_mutex = enif_mutex_create("exqlite:interrupt");
348+
memset(conn->authorizer_deny, 0, sizeof(conn->authorizer_deny));
343349
if (conn->interrupt_mutex == NULL) {
344350
// conn->db and conn->mutex are set; the destructor will clean them up.
345351
enif_release_resource(conn);
@@ -1425,6 +1431,193 @@ exqlite_set_update_hook(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
14251431
return am_ok;
14261432
}
14271433

1434+
//
1435+
// Authorizer
1436+
//
1437+
1438+
static int
1439+
authorizer_callback(void* user_data, int action, const char* arg1, const char* arg2, const char* db_name, const char* trigger)
1440+
{
1441+
connection_t* conn = (connection_t*)user_data;
1442+
if (action >= 0 && action < AUTHORIZER_DENY_SIZE && conn->authorizer_deny[action]) {
1443+
return SQLITE_DENY;
1444+
}
1445+
return SQLITE_OK;
1446+
}
1447+
1448+
// Maps atom names to SQLite authorizer action codes
1449+
static int
1450+
action_code_from_atom(ErlNifEnv* env, ERL_NIF_TERM atom)
1451+
{
1452+
char buf[32];
1453+
if (!enif_get_atom(env, atom, buf, sizeof(buf), ERL_NIF_LATIN1)) {
1454+
return -1;
1455+
}
1456+
1457+
if (strcmp(buf, "create_index") == 0) {
1458+
return SQLITE_CREATE_INDEX;
1459+
}
1460+
if (strcmp(buf, "create_table") == 0) {
1461+
return SQLITE_CREATE_TABLE;
1462+
}
1463+
if (strcmp(buf, "create_temp_index") == 0) {
1464+
return SQLITE_CREATE_TEMP_INDEX;
1465+
}
1466+
if (strcmp(buf, "create_temp_table") == 0) {
1467+
return SQLITE_CREATE_TEMP_TABLE;
1468+
}
1469+
if (strcmp(buf, "create_temp_trigger") == 0) {
1470+
return SQLITE_CREATE_TEMP_TRIGGER;
1471+
}
1472+
if (strcmp(buf, "create_temp_view") == 0) {
1473+
return SQLITE_CREATE_TEMP_VIEW;
1474+
}
1475+
if (strcmp(buf, "create_trigger") == 0) {
1476+
return SQLITE_CREATE_TRIGGER;
1477+
}
1478+
if (strcmp(buf, "create_view") == 0) {
1479+
return SQLITE_CREATE_VIEW;
1480+
}
1481+
if (strcmp(buf, "delete") == 0) {
1482+
return SQLITE_DELETE;
1483+
}
1484+
if (strcmp(buf, "drop_index") == 0) {
1485+
return SQLITE_DROP_INDEX;
1486+
}
1487+
if (strcmp(buf, "drop_table") == 0) {
1488+
return SQLITE_DROP_TABLE;
1489+
}
1490+
if (strcmp(buf, "drop_temp_index") == 0) {
1491+
return SQLITE_DROP_TEMP_INDEX;
1492+
}
1493+
if (strcmp(buf, "drop_temp_table") == 0) {
1494+
return SQLITE_DROP_TEMP_TABLE;
1495+
}
1496+
if (strcmp(buf, "drop_temp_trigger") == 0) {
1497+
return SQLITE_DROP_TEMP_TRIGGER;
1498+
}
1499+
if (strcmp(buf, "drop_temp_view") == 0) {
1500+
return SQLITE_DROP_TEMP_VIEW;
1501+
}
1502+
if (strcmp(buf, "drop_trigger") == 0) {
1503+
return SQLITE_DROP_TRIGGER;
1504+
}
1505+
if (strcmp(buf, "drop_view") == 0) {
1506+
return SQLITE_DROP_VIEW;
1507+
}
1508+
if (strcmp(buf, "insert") == 0) {
1509+
return SQLITE_INSERT;
1510+
}
1511+
if (strcmp(buf, "pragma") == 0) {
1512+
return SQLITE_PRAGMA;
1513+
}
1514+
if (strcmp(buf, "read") == 0) {
1515+
return SQLITE_READ;
1516+
}
1517+
if (strcmp(buf, "select") == 0) {
1518+
return SQLITE_SELECT;
1519+
}
1520+
if (strcmp(buf, "transaction") == 0) {
1521+
return SQLITE_TRANSACTION;
1522+
}
1523+
if (strcmp(buf, "update") == 0) {
1524+
return SQLITE_UPDATE;
1525+
}
1526+
if (strcmp(buf, "attach") == 0) {
1527+
return SQLITE_ATTACH;
1528+
}
1529+
if (strcmp(buf, "detach") == 0) {
1530+
return SQLITE_DETACH;
1531+
}
1532+
if (strcmp(buf, "alter_table") == 0) {
1533+
return SQLITE_ALTER_TABLE;
1534+
}
1535+
if (strcmp(buf, "reindex") == 0) {
1536+
return SQLITE_REINDEX;
1537+
}
1538+
if (strcmp(buf, "analyze") == 0) {
1539+
return SQLITE_ANALYZE;
1540+
}
1541+
if (strcmp(buf, "create_vtable") == 0) {
1542+
return SQLITE_CREATE_VTABLE;
1543+
}
1544+
if (strcmp(buf, "drop_vtable") == 0) {
1545+
return SQLITE_DROP_VTABLE;
1546+
}
1547+
if (strcmp(buf, "function") == 0) {
1548+
return SQLITE_FUNCTION;
1549+
}
1550+
if (strcmp(buf, "savepoint") == 0) {
1551+
return SQLITE_SAVEPOINT;
1552+
}
1553+
if (strcmp(buf, "recursive") == 0) {
1554+
return SQLITE_RECURSIVE;
1555+
}
1556+
1557+
return -1;
1558+
}
1559+
1560+
// set_authorizer(conn, deny_list) -> :ok | {:error, reason}
1561+
// deny_list is a list of atoms: [:attach, :detach, :pragma, ...]
1562+
// Pass an empty list to clear the authorizer.
1563+
ERL_NIF_TERM
1564+
exqlite_set_authorizer(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
1565+
{
1566+
assert(env);
1567+
connection_t* conn = NULL;
1568+
1569+
if (argc != 2) {
1570+
return enif_make_badarg(env);
1571+
}
1572+
1573+
if (!enif_get_resource(env, argv[0], connection_type, (void**)&conn)) {
1574+
return am_invalid_connection;
1575+
}
1576+
1577+
connection_acquire_lock(conn);
1578+
1579+
if (conn->db == NULL) {
1580+
connection_release_lock(conn);
1581+
return make_error_tuple(env, am_connection_closed);
1582+
}
1583+
1584+
// Parse the deny list
1585+
unsigned int list_len;
1586+
if (!enif_get_list_length(env, argv[1], &list_len)) {
1587+
connection_release_lock(conn);
1588+
return enif_make_badarg(env);
1589+
}
1590+
1591+
if (list_len == 0) {
1592+
// Empty list: clear the authorizer
1593+
memset(conn->authorizer_deny, 0, sizeof(conn->authorizer_deny));
1594+
sqlite3_set_authorizer(conn->db, NULL, NULL);
1595+
connection_release_lock(conn);
1596+
return am_ok;
1597+
}
1598+
1599+
// Validate all atoms before mutating state — a bad atom in the list
1600+
// should not clear an existing authorizer as a side effect.
1601+
int new_deny[AUTHORIZER_DENY_SIZE] = {0};
1602+
ERL_NIF_TERM head, tail = argv[1];
1603+
while (enif_get_list_cell(env, tail, &head, &tail)) {
1604+
int code = action_code_from_atom(env, head);
1605+
if (code < 0 || code >= AUTHORIZER_DENY_SIZE) {
1606+
connection_release_lock(conn);
1607+
return enif_make_badarg(env);
1608+
}
1609+
new_deny[code] = 1;
1610+
}
1611+
1612+
// Validation passed — apply atomically
1613+
memcpy(conn->authorizer_deny, new_deny, sizeof(conn->authorizer_deny));
1614+
sqlite3_set_authorizer(conn->db, authorizer_callback, conn);
1615+
1616+
connection_release_lock(conn);
1617+
1618+
return am_ok;
1619+
}
1620+
14281621
//
14291622
// Log Notifications
14301623
//
@@ -1590,6 +1783,7 @@ static ErlNifFunc nif_funcs[] = {
15901783
{"release", 2, exqlite_release, ERL_NIF_DIRTY_JOB_IO_BOUND},
15911784
{"enable_load_extension", 2, exqlite_enable_load_extension, ERL_NIF_DIRTY_JOB_IO_BOUND},
15921785
{"set_update_hook", 2, exqlite_set_update_hook, ERL_NIF_DIRTY_JOB_IO_BOUND},
1786+
{"set_authorizer", 2, exqlite_set_authorizer, ERL_NIF_DIRTY_JOB_IO_BOUND},
15931787
{"set_log_hook", 1, exqlite_set_log_hook, ERL_NIF_DIRTY_JOB_IO_BOUND},
15941788
{"interrupt", 1, exqlite_interrupt, ERL_NIF_DIRTY_JOB_IO_BOUND},
15951789
{"errmsg", 1, exqlite_errmsg},

lib/exqlite/sqlite3.ex

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,38 @@ defmodule Exqlite.Sqlite3 do
405405
Sqlite3NIF.set_update_hook(conn, pid)
406406
end
407407

408+
@doc """
409+
Set an authorizer that denies specific SQL operations.
410+
411+
Accepts a list of action atoms to deny. Any SQL statement that triggers a
412+
denied action will fail with a "not authorized" error during preparation.
413+
414+
Pass an empty list to clear the authorizer.
415+
416+
## Action atoms
417+
418+
`:attach`, `:detach`, `:pragma`, `:insert`, `:update`, `:delete`,
419+
`:create_table`, `:drop_table`, `:create_index`, `:drop_index`,
420+
`:create_trigger`, `:drop_trigger`, `:create_view`, `:drop_view`,
421+
`:alter_table`, `:reindex`, `:analyze`, `:function`, `:savepoint`,
422+
`:transaction`, `:read`, `:select`, `:recursive`,
423+
`:create_temp_table`, `:create_temp_index`, `:create_temp_trigger`,
424+
`:create_temp_view`, `:drop_temp_table`, `:drop_temp_index`,
425+
`:drop_temp_trigger`, `:drop_temp_view`, `:create_vtable`, `:drop_vtable`
426+
427+
## Examples
428+
429+
# Block ATTACH and DETACH (prevent cross-database reads)
430+
:ok = Sqlite3.set_authorizer(conn, [:attach, :detach])
431+
432+
# Clear the authorizer
433+
:ok = Sqlite3.set_authorizer(conn, [])
434+
"""
435+
@spec set_authorizer(db(), [atom()]) :: :ok | {:error, reason()}
436+
def set_authorizer(conn, deny_list) when is_list(deny_list) do
437+
Sqlite3NIF.set_authorizer(conn, deny_list)
438+
end
439+
408440
@doc """
409441
Send log messages to a process.
410442

lib/exqlite/sqlite3_nif.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ defmodule Exqlite.Sqlite3NIF do
6666
@spec set_update_hook(db(), pid()) :: :ok | {:error, reason()}
6767
def set_update_hook(_conn, _pid), do: :erlang.nif_error(:not_loaded)
6868

69+
@spec set_authorizer(db(), [atom()]) :: :ok | {:error, reason()}
70+
def set_authorizer(_conn, _deny_list), do: :erlang.nif_error(:not_loaded)
71+
6972
@spec set_log_hook(pid()) :: :ok | {:error, reason()}
7073
def set_log_hook(_pid), do: :erlang.nif_error(:not_loaded)
7174

0 commit comments

Comments
 (0)