diff --git a/c_src/sqlite3_nif.c b/c_src/sqlite3_nif.c index fcb0109..c9f591d 100644 --- a/c_src/sqlite3_nif.c +++ b/c_src/sqlite3_nif.c @@ -48,12 +48,17 @@ static sqlite3_mem_methods default_alloc_methods = {0}; ErlNifPid* log_hook_pid = NULL; ErlNifMutex* log_hook_mutex = NULL; +// Denied authorizer action codes. Sized to 64 for margin — highest +// currently defined SQLite action code is SQLITE_RECURSIVE (33). +#define AUTHORIZER_DENY_SIZE 64 + typedef struct connection { sqlite3* db; ErlNifMutex* mutex; ErlNifMutex* interrupt_mutex; ErlNifPid update_hook_pid; + int authorizer_deny[AUTHORIZER_DENY_SIZE]; } connection_t; typedef struct statement @@ -340,6 +345,7 @@ exqlite_open(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) conn->db = db; conn->mutex = mutex; conn->interrupt_mutex = enif_mutex_create("exqlite:interrupt"); + memset(conn->authorizer_deny, 0, sizeof(conn->authorizer_deny)); if (conn->interrupt_mutex == NULL) { // conn->db and conn->mutex are set; the destructor will clean them up. enif_release_resource(conn); @@ -1425,6 +1431,193 @@ exqlite_set_update_hook(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) return am_ok; } +// +// Authorizer +// + +static int +authorizer_callback(void* user_data, int action, const char* arg1, const char* arg2, const char* db_name, const char* trigger) +{ + connection_t* conn = (connection_t*)user_data; + if (action >= 0 && action < AUTHORIZER_DENY_SIZE && conn->authorizer_deny[action]) { + return SQLITE_DENY; + } + return SQLITE_OK; +} + +// Maps atom names to SQLite authorizer action codes +static int +action_code_from_atom(ErlNifEnv* env, ERL_NIF_TERM atom) +{ + char buf[32]; + if (!enif_get_atom(env, atom, buf, sizeof(buf), ERL_NIF_LATIN1)) { + return -1; + } + + if (strcmp(buf, "create_index") == 0) { + return SQLITE_CREATE_INDEX; + } + if (strcmp(buf, "create_table") == 0) { + return SQLITE_CREATE_TABLE; + } + if (strcmp(buf, "create_temp_index") == 0) { + return SQLITE_CREATE_TEMP_INDEX; + } + if (strcmp(buf, "create_temp_table") == 0) { + return SQLITE_CREATE_TEMP_TABLE; + } + if (strcmp(buf, "create_temp_trigger") == 0) { + return SQLITE_CREATE_TEMP_TRIGGER; + } + if (strcmp(buf, "create_temp_view") == 0) { + return SQLITE_CREATE_TEMP_VIEW; + } + if (strcmp(buf, "create_trigger") == 0) { + return SQLITE_CREATE_TRIGGER; + } + if (strcmp(buf, "create_view") == 0) { + return SQLITE_CREATE_VIEW; + } + if (strcmp(buf, "delete") == 0) { + return SQLITE_DELETE; + } + if (strcmp(buf, "drop_index") == 0) { + return SQLITE_DROP_INDEX; + } + if (strcmp(buf, "drop_table") == 0) { + return SQLITE_DROP_TABLE; + } + if (strcmp(buf, "drop_temp_index") == 0) { + return SQLITE_DROP_TEMP_INDEX; + } + if (strcmp(buf, "drop_temp_table") == 0) { + return SQLITE_DROP_TEMP_TABLE; + } + if (strcmp(buf, "drop_temp_trigger") == 0) { + return SQLITE_DROP_TEMP_TRIGGER; + } + if (strcmp(buf, "drop_temp_view") == 0) { + return SQLITE_DROP_TEMP_VIEW; + } + if (strcmp(buf, "drop_trigger") == 0) { + return SQLITE_DROP_TRIGGER; + } + if (strcmp(buf, "drop_view") == 0) { + return SQLITE_DROP_VIEW; + } + if (strcmp(buf, "insert") == 0) { + return SQLITE_INSERT; + } + if (strcmp(buf, "pragma") == 0) { + return SQLITE_PRAGMA; + } + if (strcmp(buf, "read") == 0) { + return SQLITE_READ; + } + if (strcmp(buf, "select") == 0) { + return SQLITE_SELECT; + } + if (strcmp(buf, "transaction") == 0) { + return SQLITE_TRANSACTION; + } + if (strcmp(buf, "update") == 0) { + return SQLITE_UPDATE; + } + if (strcmp(buf, "attach") == 0) { + return SQLITE_ATTACH; + } + if (strcmp(buf, "detach") == 0) { + return SQLITE_DETACH; + } + if (strcmp(buf, "alter_table") == 0) { + return SQLITE_ALTER_TABLE; + } + if (strcmp(buf, "reindex") == 0) { + return SQLITE_REINDEX; + } + if (strcmp(buf, "analyze") == 0) { + return SQLITE_ANALYZE; + } + if (strcmp(buf, "create_vtable") == 0) { + return SQLITE_CREATE_VTABLE; + } + if (strcmp(buf, "drop_vtable") == 0) { + return SQLITE_DROP_VTABLE; + } + if (strcmp(buf, "function") == 0) { + return SQLITE_FUNCTION; + } + if (strcmp(buf, "savepoint") == 0) { + return SQLITE_SAVEPOINT; + } + if (strcmp(buf, "recursive") == 0) { + return SQLITE_RECURSIVE; + } + + return -1; +} + +// set_authorizer(conn, deny_list) -> :ok | {:error, reason} +// deny_list is a list of atoms: [:attach, :detach, :pragma, ...] +// Pass an empty list to clear the authorizer. +ERL_NIF_TERM +exqlite_set_authorizer(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) +{ + assert(env); + connection_t* conn = NULL; + + if (argc != 2) { + return enif_make_badarg(env); + } + + if (!enif_get_resource(env, argv[0], connection_type, (void**)&conn)) { + return am_invalid_connection; + } + + connection_acquire_lock(conn); + + if (conn->db == NULL) { + connection_release_lock(conn); + return make_error_tuple(env, am_connection_closed); + } + + // Parse the deny list + unsigned int list_len; + if (!enif_get_list_length(env, argv[1], &list_len)) { + connection_release_lock(conn); + return enif_make_badarg(env); + } + + if (list_len == 0) { + // Empty list: clear the authorizer + memset(conn->authorizer_deny, 0, sizeof(conn->authorizer_deny)); + sqlite3_set_authorizer(conn->db, NULL, NULL); + connection_release_lock(conn); + return am_ok; + } + + // Validate all atoms before mutating state — a bad atom in the list + // should not clear an existing authorizer as a side effect. + int new_deny[AUTHORIZER_DENY_SIZE] = {0}; + ERL_NIF_TERM head, tail = argv[1]; + while (enif_get_list_cell(env, tail, &head, &tail)) { + int code = action_code_from_atom(env, head); + if (code < 0 || code >= AUTHORIZER_DENY_SIZE) { + connection_release_lock(conn); + return enif_make_badarg(env); + } + new_deny[code] = 1; + } + + // Validation passed — apply atomically + memcpy(conn->authorizer_deny, new_deny, sizeof(conn->authorizer_deny)); + sqlite3_set_authorizer(conn->db, authorizer_callback, conn); + + connection_release_lock(conn); + + return am_ok; +} + // // Log Notifications // @@ -1590,6 +1783,7 @@ static ErlNifFunc nif_funcs[] = { {"release", 2, exqlite_release, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"enable_load_extension", 2, exqlite_enable_load_extension, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"set_update_hook", 2, exqlite_set_update_hook, ERL_NIF_DIRTY_JOB_IO_BOUND}, + {"set_authorizer", 2, exqlite_set_authorizer, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"set_log_hook", 1, exqlite_set_log_hook, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"interrupt", 1, exqlite_interrupt, ERL_NIF_DIRTY_JOB_IO_BOUND}, {"errmsg", 1, exqlite_errmsg}, diff --git a/lib/exqlite/sqlite3.ex b/lib/exqlite/sqlite3.ex index 942cb19..7efad19 100644 --- a/lib/exqlite/sqlite3.ex +++ b/lib/exqlite/sqlite3.ex @@ -405,6 +405,38 @@ defmodule Exqlite.Sqlite3 do Sqlite3NIF.set_update_hook(conn, pid) end + @doc """ + Set an authorizer that denies specific SQL operations. + + Accepts a list of action atoms to deny. Any SQL statement that triggers a + denied action will fail with a "not authorized" error during preparation. + + Pass an empty list to clear the authorizer. + + ## Action atoms + + `:attach`, `:detach`, `:pragma`, `:insert`, `:update`, `:delete`, + `:create_table`, `:drop_table`, `:create_index`, `:drop_index`, + `:create_trigger`, `:drop_trigger`, `:create_view`, `:drop_view`, + `:alter_table`, `:reindex`, `:analyze`, `:function`, `:savepoint`, + `:transaction`, `:read`, `:select`, `:recursive`, + `:create_temp_table`, `:create_temp_index`, `:create_temp_trigger`, + `:create_temp_view`, `:drop_temp_table`, `:drop_temp_index`, + `:drop_temp_trigger`, `:drop_temp_view`, `:create_vtable`, `:drop_vtable` + + ## Examples + + # Block ATTACH and DETACH (prevent cross-database reads) + :ok = Sqlite3.set_authorizer(conn, [:attach, :detach]) + + # Clear the authorizer + :ok = Sqlite3.set_authorizer(conn, []) + """ + @spec set_authorizer(db(), [atom()]) :: :ok | {:error, reason()} + def set_authorizer(conn, deny_list) when is_list(deny_list) do + Sqlite3NIF.set_authorizer(conn, deny_list) + end + @doc """ Send log messages to a process. diff --git a/lib/exqlite/sqlite3_nif.ex b/lib/exqlite/sqlite3_nif.ex index d9416a2..4ca3f6e 100644 --- a/lib/exqlite/sqlite3_nif.ex +++ b/lib/exqlite/sqlite3_nif.ex @@ -66,6 +66,9 @@ defmodule Exqlite.Sqlite3NIF do @spec set_update_hook(db(), pid()) :: :ok | {:error, reason()} def set_update_hook(_conn, _pid), do: :erlang.nif_error(:not_loaded) + @spec set_authorizer(db(), [atom()]) :: :ok | {:error, reason()} + def set_authorizer(_conn, _deny_list), do: :erlang.nif_error(:not_loaded) + @spec set_log_hook(pid()) :: :ok | {:error, reason()} def set_log_hook(_pid), do: :erlang.nif_error(:not_loaded) diff --git a/test/exqlite/sqlite3_test.exs b/test/exqlite/sqlite3_test.exs index f9db1cf..190d738 100644 --- a/test/exqlite/sqlite3_test.exs +++ b/test/exqlite/sqlite3_test.exs @@ -762,6 +762,154 @@ defmodule Exqlite.Sqlite3Test do end end + describe "set_authorizer/2" do + setup do + {:ok, path} = Temp.path() + {:ok, conn} = Sqlite3.open(path) + :ok = Sqlite3.execute(conn, "create table test(num integer)") + + {:ok, other_path} = Temp.path() + {:ok, other_conn} = Sqlite3.open(other_path) + :ok = Sqlite3.execute(other_conn, "create table other(val text)") + :ok = Sqlite3.execute(other_conn, "insert into other values ('secret')") + Sqlite3.close(other_conn) + + on_exit(fn -> + Sqlite3.close(conn) + File.rm(path) + File.rm(other_path) + end) + + [conn: conn, other_path: other_path] + end + + test "denies ATTACH when :attach is in deny list", context do + :ok = Sqlite3.set_authorizer(context.conn, [:attach]) + + assert {:error, "not authorized"} = + Sqlite3.execute( + context.conn, + "ATTACH DATABASE '#{context.other_path}' AS other_db" + ) + end + + test "allows normal operations when only :attach is denied", context do + :ok = Sqlite3.set_authorizer(context.conn, [:attach]) + :ok = Sqlite3.execute(context.conn, "insert into test values (42)") + + {:ok, stmt} = Sqlite3.prepare(context.conn, "select * from test") + assert {:row, [42]} = Sqlite3.step(context.conn, stmt) + Sqlite3.release(context.conn, stmt) + end + + test "clears authorizer with empty deny list", context do + :ok = Sqlite3.set_authorizer(context.conn, [:attach]) + + assert {:error, "not authorized"} = + Sqlite3.execute( + context.conn, + "ATTACH DATABASE '#{context.other_path}' AS other_db" + ) + + :ok = Sqlite3.set_authorizer(context.conn, []) + + assert :ok = + Sqlite3.execute( + context.conn, + "ATTACH DATABASE '#{context.other_path}' AS other_db2" + ) + end + + test "denies multiple action types", context do + :ok = Sqlite3.set_authorizer(context.conn, [:attach, :detach, :pragma]) + + assert {:error, "not authorized"} = + Sqlite3.execute( + context.conn, + "ATTACH DATABASE '#{context.other_path}' AS other_db" + ) + + assert {:error, "not authorized"} = + Sqlite3.execute(context.conn, "PRAGMA table_info(test)") + end + + test "denies savepoint (high action code)", context do + :ok = Sqlite3.set_authorizer(context.conn, [:savepoint]) + assert {:error, "not authorized"} = Sqlite3.execute(context.conn, "SAVEPOINT sp1") + end + + test "does not clear existing authorizer on invalid input", context do + :ok = Sqlite3.set_authorizer(context.conn, [:attach]) + + # Bad atom should raise without clearing the existing :attach deny + assert_raise ArgumentError, fn -> + Sqlite3.set_authorizer(context.conn, [:attach, :not_a_real_action]) + end + + # Original authorizer should still be active + assert {:error, "not authorized"} = + Sqlite3.execute( + context.conn, + "ATTACH DATABASE '#{context.other_path}' AS other_db" + ) + end + + test "denies all action codes at once without segfault", context do + all_actions = [ + :create_index, + :create_table, + :create_temp_index, + :create_temp_table, + :create_temp_trigger, + :create_temp_view, + :create_trigger, + :create_view, + :delete, + :drop_index, + :drop_table, + :drop_temp_index, + :drop_temp_table, + :drop_temp_trigger, + :drop_temp_view, + :drop_trigger, + :drop_view, + :insert, + :pragma, + :read, + :select, + :transaction, + :update, + :attach, + :detach, + :alter_table, + :reindex, + :analyze, + :create_vtable, + :drop_vtable, + :function, + :savepoint, + :recursive + ] + + :ok = Sqlite3.set_authorizer(context.conn, all_actions) + + assert {:error, "not authorized"} = + Sqlite3.execute(context.conn, "ATTACH DATABASE ':memory:' AS x") + + assert {:error, "not authorized"} = Sqlite3.execute(context.conn, "SAVEPOINT sp1") + + # Clear and verify normal operations work again + :ok = Sqlite3.set_authorizer(context.conn, []) + :ok = Sqlite3.execute(context.conn, "insert into test values (99)") + end + + test "raises for invalid action atoms", context do + assert_raise ArgumentError, fn -> + Sqlite3.set_authorizer(context.conn, [:not_a_real_action]) + end + end + end + describe "set_log_hook/1" do setup do {:ok, conn} = Sqlite3.open(":memory:")