Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions c_src/sqlite3_nif.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +51 to +53
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you able to come up with a test that reaches the max deny size of 33? I just want to ensure a segfault does not occur.


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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1425,6 +1431,160 @@ 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;
Comment thread
WhiskeyTuesday marked this conversation as resolved.
Outdated

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
//
Expand Down Expand Up @@ -1590,6 +1750,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},
Expand Down
32 changes: 32 additions & 0 deletions lib/exqlite/sqlite3.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions lib/exqlite/sqlite3_nif.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
99 changes: 99 additions & 0 deletions test/exqlite/sqlite3_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,105 @@ 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 "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:")
Expand Down
Loading