Security
View SourceThis page is xqlite's threat model: what the library guards for you, what it deliberately leaves in your hands, and the sharp edges worth knowing before you point untrusted input at a database. It is not a hardening checklist you tick once — it is a description of where the trust boundary sits and how xqlite behaves on either side of it.
The trust boundary
SQLite is an in-process library, not a server. xqlite links it straight into the BEAM (bundled, statically) and talks to it over a NIF — there is no socket, no daemon, no listening port of xqlite's own. That removes an entire class of attack surface: nothing to authenticate to, nothing to firewall. What replaces it is closer to home. The database file, the SQL you hand the engine, and any extension you load all run with the full privileges of your BEAM OS process. Those three inputs are the trust boundary. The rest of this page is about controlling them.
The upstream project keeps a matching document worth reading alongside this one: SQLite — Security.
Thread-safety model
Every connection handle is a reference() backing a Mutex<Connection>
on the Rust side (inside a ResourceArc, which gives it Arc sharing
semantics). The mutex serializes access: a handle may be passed between
BEAM processes freely, and concurrent calls on the same handle take turns
rather than racing. There is no shared mutable SQLite state exposed to
Elixir that the mutex does not cover.
This mutex is not redundant with SQLite's own locking — the two are
complementary. rusqlite opens every connection with
SQLITE_OPEN_NO_MUTEX, which disables SQLite's internal per-connection
mutex for speed. That is safe precisely because xqlite's
Mutex<Connection> is the thing serializing access instead. Removing one
without the other would be a data race. Read concurrency is meant to come
from a pool of independent handles (as the Ecto adapter does), not from
sharing one handle across schedulers.
Two by-design operations hold this mutex across a blocking call, pinning
the connection for that call's duration. A busy retry policy's sleep_ms
(Xqlite.set_busy_policy/2) sleeps on the mutex-holding thread between
attempts; and in WAL mode xqlite runs the emulated autocheckpoint — a
passive checkpoint, i.e. real file I/O — inside its WAL hook on the
committing thread while the mutex is held. Neither is a bug (it is simply
where SQLite invokes the callback), but both make other operations on the
same connection wait. Other connections are never affected.
One rule the mutex cannot enforce for you: a raw handle must not
outlive the connection it came from. Prepared statements, streams, and
incremental-blob handles all hold a pointer into their connection's
SQLite state. Closing the connection while one of them is live is a
usage pattern to avoid — not because it will crash (it won't; see the
next section) but because it leaks. Finalize statements, drain or close
streams, close blobs, and delete sessions before Xqlite.close/1.
Resource lifecycle: close raw handles before the connection
These are the "hold it wrong" edges. All of them are safe — no crash, no corruption, no undefined behavior — but each leaks a resource that a small change to your teardown order avoids. Do the first thing, not the second.
Close incremental blobs before the connection. An open blob (from
XqliteNIF.blob_open/6) registers an internal statement inside SQLite, so
sqlite3_close on a connection with a live blob returns SQLITE_BUSY and
SQLite refuses to free the connection. xqlite does not force the issue, so
that connection's sqlite3 handle stays resident until the OS process
exits. Call XqliteNIF.blob_close/1 (or let the blob be garbage-collected)
before closing the connection, and the handle frees normally. Prepared
statements and streams behave the same way — Xqlite.close/1 documents
that a connection closed with statements outstanding keeps its handle
alive until the process exits, so Xqlite.finalize/1 first.
Delete sessions before the connection. A session object
(XqliteNIF.session_new/1) is torn down under the connection mutex. If
you explicitly close the connection while a session handle is still
referenced, xqlite cannot safely run the session's teardown against a
freed database, so it leaks the (small) session object rather than risk a
use-after-free. Call XqliteNIF.session_delete/1 before Xqlite.close/1.
Neither leak grows without bound during normal use — it is one leaked object per mis-ordered teardown, reclaimed when the OS process exits. But in a long-lived system that opens and closes many connections, mis-ordered teardown is a slow leak worth designing out.
Loading an extension runs native code in your process
This is the single most consequential capability in the library.
An extension is arbitrary code execution
Xqlite.load_extension/3 maps a native shared library into your BEAM
OS process and calls its init function. From that moment the extension
runs with the full privileges of your application — it can read and
write any file the process can, open sockets, and corrupt process
memory. There is no sandbox. Only load extensions you fully trust,
from paths an attacker cannot influence.
Because of that, extension loading is off by default and gated twice: the
SQLite build has the capability compiled in, but it stays disabled until
you explicitly turn it on with Xqlite.enable_load_extension/2, and the
recommended pattern is to disable it again immediately after loading:
{:ok, conn} = Xqlite.open("app.db")
:ok = Xqlite.enable_load_extension(conn, true)
{:ok, _} = Xqlite.load_extension(conn, "/opt/ext/mod_spatialite")
:ok = Xqlite.enable_load_extension(conn, false)Re-disabling shrinks the window in which a later load_extension — yours
or one smuggled in through SQL — can pull in code. Never enable extension
loading on a connection that also runs untrusted SQL. See
SQLite — Loadable Extensions.
Restricting untrusted SQL: the authorizer
When you must run SQL you did not write — a reporting console, a
user-supplied filter — install a deny-list authorizer. SQLite consults it
while preparing every statement, before any row is touched, and xqlite
turns a denied action into a structured {:error, {:authorization_denied, message}}:
{:ok, conn} = Xqlite.open_in_memory()
# A read-only console: forbid every mutating and structural action.
:ok =
Xqlite.set_authorizer(conn, [
:insert,
:update,
:delete,
:drop_table,
:alter_table,
:attach,
:pragma
])
{:error, {:authorization_denied, _}} =
XqliteNIF.execute(conn, "DELETE FROM audit_log", [])Xqlite.remove_authorizer/1 clears it. Two limits are worth stating
plainly, because they shape what the authorizer can and cannot promise:
- Action-kind granularity only. The decision is made on the action
kind (
:delete,:pragma,:attach, …). The table, column, and database arguments SQLite passes are ignored, so you cannot yet allowDELETEon one table while denying it on another. - Deny-only. An action is allowed or denied; SQLite's
IGNOREdisposition is not exposed.
One caveat with security relevance: denying :pragma also disables
Xqlite.get_pragma/2, Xqlite.set_pragma/3, and the schema-introspection
helpers, since those run PRAGMA statements. Deny it only when you intend
to lock those paths out too. The authorizer restricts what untrusted SQL
may do; it is not a substitute for parameterizing values — use both. See
SQLite — Compile-time Authorization.
SQL injection: always parameterize
Every query and execute function takes a parameter list. Use it. Never build SQL by interpolating user input into the statement string — string interpolation is how a value becomes executable syntax.
# WRONG — the value is now part of the SQL grammar.
XqliteNIF.query(conn, "SELECT * FROM users WHERE email = '#{email}'", [])
# RIGHT — the value is bound, never parsed as SQL.
XqliteNIF.query(conn, "SELECT * FROM users WHERE email = ?1", [email])Bound parameters are passed to SQLite out-of-band from the SQL text, so
no contents of email — quotes, semicolons, comment markers — can change
what the statement does. This holds everywhere xqlite accepts a parameter
list, including the match term of an FTS5 query and the arguments of an
extension function. Identifiers (table and column names) cannot be
parameterized in SQL; when those are dynamic, validate them against an
allow-list you control rather than interpolating raw input.
Panic-freedom, and where it ends
xqlite's standing guarantee is that a call into the library does not crash the BEAM. It holds through two layers.
First, every NIF invocation is wrapped so a Rust panic is caught before it
can unwind into C. A panic inside a NIF body — or inside the code that
encodes a return value back to an Elixir term — is turned into a raise
of :nif_panicked in the calling process, which that process can catch
like any other error; the VM stays up. (This is Rustler's documented
behavior, and xqlite relies on it.)
Second, and distinct from panics: xqlite's own expected failures are
never raised and never panics. They come back as structured {:error, reason} tuples — :connection_closed, {:utf8_error, column, reason},
{:authorization_denied, message}, and the rest of the error vocabulary.
A panic means "a bug reached a place it shouldn't"; an {:error, _} tuple
means "a thing you asked for could not be done." They are not the same
channel, and you should match on the tuples.
The one place this net does not reach is a resource destructor — the teardown that runs when a connection, statement, stream, blob, or session handle is garbage-collected. Destructors are invoked by the BEAM's own memory management, outside the per-call panic guard, so a panic there would unwind into C and take down the VM. That boundary is known and designed around: every destructor xqlite ships is written to be panic-proof — teardown runs under the connection mutex, failures are logged rather than unwrapped, and nothing in the drop path calls an operation that can panic. It is a guarantee with a named edge, not an unconditional one.
Defense in depth: API_ARMOR
xqlite's raw FFI paths — the code in the stream, statement, and blob
resources that calls sqlite3_step, sqlite3_bind_*, sqlite3_column_*,
and sqlite3_finalize on raw pointers — are guarded on the Rust side by a
mutex, Option, and AtomicPtr. Beneath that, the bundled SQLite is
compiled with
SQLITE_ENABLE_API_ARMOR,
which adds NULL-pointer and invalid-argument checks at every SQLite C API
entry point. The effect is a safety net: were a bug ever to reach the C
API with a bad argument, API_ARMOR turns it into a returned
SQLITE_MISUSE error instead of undefined behavior. The cost is
negligible and it is always on.
Data-handling edges
A few smaller behaviors that occasionally surprise, all deliberate:
- NUL bytes in SQL text are rejected, not truncated. SQL passed to the
engine is converted to a C string; a statement string containing an
interior NUL (
\0) byte returns{:error, :null_byte_in_string}rather than being silently cut off at the NUL. Silent truncation is a classic way for a crafted value to shorten a statement into something unintended; xqlite refuses instead. - Invalid UTF-8 in a TEXT column is surfaced, not mangled. Reading a
TEXTvalue whose bytes are not valid UTF-8 returns{:error, {:utf8_error, column, reason}}, naming the column. xqlite does not lossily transcode; you learn that the stored bytes are not the text they claim to be. - Binary parameters dispatch on validity. An Elixir binary bound as a
parameter is stored as SQLite
TEXTwhen it is valid UTF-8 and as aBLOBotherwise. Bind explicitly typed values if you need a specific column affinity regardless of contents. :memory:vs file is a trust and durability choice. An in-memory database (Xqlite.open_in_memory/1) has no on-disk footprint and is gone when its connection closes — nothing to leak to the filesystem, and nothing to persist. A file-backed database is exactly as trustworthy as the file: xqlite opens whatever image the path points at, so treat the database file itself as an input under your control, and deserialize only images you trust (Xqlite.deserialize/4replaces a connection's contents wholesale with the bytes you hand it).