diff --git a/CHANGELOG.md b/CHANGELOG.md index 9311b3a..19a3d7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: `:authorizer` connection option to deny SQL actions on `DBConnection` managed connections via `Exqlite.Sqlite3.set_authorizer/2`. + ## v0.40.0 - changed: Update sqlite to `3.53.4`. diff --git a/lib/exqlite/connection.ex b/lib/exqlite/connection.ex index d08b4bb..11bd6b8 100644 --- a/lib/exqlite/connection.ex +++ b/lib/exqlite/connection.ex @@ -144,6 +144,9 @@ defmodule Exqlite.Connection do `1` disable the progress handler, which reduces per-op overhead but means `interrupt/1` and `cancel/1` only take effect once SQLite returns from the current call. + * `:authorizer` - A list of SQL actions to deny on the connection. + Example: `authorizer: [:attach, :detach]`. Defaults to `[]` (no authorizer). + See `Exqlite.Sqlite3.set_authorizer/2` for the list of action atoms. * `:chunk_size` - The chunk size for bulk fetching. Defaults to `50`. * `:key` - Optional key to set during database initialization. This PRAGMA is often used to set up database level encryption. @@ -554,6 +557,15 @@ defmodule Exqlite.Connection do end end + # Must run last in `do_connect/2`. Connection setup issues its own PRAGMA and + # SELECT statements, which would be rejected if the authorizer denied them. + defp set_authorizer(db, options) do + case Keyword.get(options, :authorizer, []) do + [] -> :ok + deny_list -> Sqlite3.set_authorizer(db, deny_list) + end + end + defp load_extensions(db, options) do global_extensions = Application.get_env(:exqlite, :load_extensions, []) @@ -601,7 +613,8 @@ defmodule Exqlite.Connection do :ok <- set_soft_heap_limit(db, options), :ok <- set_hard_heap_limit(db, options), :ok <- load_extensions(db, options), - :ok <- deserialize(db, options) do + :ok <- deserialize(db, options), + :ok <- set_authorizer(db, options) do state = %__MODULE__{ db: db, default_transaction_mode: diff --git a/test/exqlite/connection_test.exs b/test/exqlite/connection_test.exs index 79de935..8755658 100644 --- a/test/exqlite/connection_test.exs +++ b/test/exqlite/connection_test.exs @@ -183,6 +183,53 @@ defmodule Exqlite.ConnectionTest do File.rm(path) end + + test "setting authorizer denies listed actions" do + path = Temp.path!() + other_path = Temp.path!() + + {:ok, state} = Connection.connect(database: path, authorizer: [:attach]) + + assert {:error, "not authorized"} = + Sqlite3.execute(state.db, "ATTACH DATABASE '#{other_path}' AS other") + + # Actions not in the deny list still work + assert :ok = Sqlite3.execute(state.db, "CREATE TABLE test (id INTEGER)") + + File.rm(path) + File.rm(other_path) + end + + test "authorizer is enforced through DBConnection callbacks" do + path = Temp.path!() + other_path = Temp.path!() + + {:ok, state} = Connection.connect(database: path, authorizer: [:attach]) + query = %Query{statement: "ATTACH DATABASE '#{other_path}' AS other"} + + assert {:error, %Exqlite.Error{message: "not authorized"}, _state} = + Connection.handle_prepare(query, [], state) + + File.rm(path) + File.rm(other_path) + end + + test "authorizer does not interfere with connection setup" do + path = Temp.path!() + + # Setup runs PRAGMA statements, so denying :pragma must not break connect + {:ok, state} = + Connection.connect(database: path, authorizer: [:pragma], journal_mode: :wal) + + assert {:error, "not authorized"} = + Sqlite3.execute(state.db, "PRAGMA journal_mode = delete") + + # Verify the setup pragma took effect via an unrestricted connection + {:ok, other} = Connection.connect(database: path) + assert {:ok, "wal"} = get_pragma(other.db, :journal_mode) + + File.rm(path) + end end defp get_pragma(db, pragma_name) do