Spire is designed to safely execute untrusted SQL — queries provided by your customers, by an LLM, or by anyone else you don't fully trust — against your own data sources. This document describes the threat model: what Spire defends against, what it doesn't, and what's on you.
If you find a vulnerability, please open a private GitHub Security Advisory rather than a public issue.
- Cross-tenant reads —
Spire.Policy.Table.enforced_whereis injected as an{:enforced_filter, _}op immediately after every matching scan. The op is structurally distinct from:filterand per SPEC.md §8.3 cannot be removed, merged, or reordered by any optimizer. In JOINs the scope applies on every side. Verified bybench/multi_tenant_demo.exs. - Column exfiltration — only columns in
Policy.Table.columnsmay appear in SELECT contexts.filter_only_columnsmay appear in WHERE / GROUP BY / HAVING / ORDER BY but are rejected from SELECT. - Table / function escalation — positive allowlist for both. A table or function not in the policy is rejected before the engine or the SQL backend ever sees it.
- Atom-table DoS — the lexer stores customer-supplied parameter
names and CAST types as binaries, not atoms. Tested in
test/spire/red_team_test.exs: 100 distinct param names and 100 distinct CAST types each grow the atom table by at most ~10. - Regex backtracking on LIKE —
Spire.Engine.Nulluses a hand-rolled two-pointer matcher rather than:re. Worst-case O(|str| · |pattern|) on adversarial patterns; no exponential blowup. - Query complexity —
Policy.max_joins,max_result_limit,require_limitare enforced at analyze time. - Deep AST nesting — 200-deep NOT chains and 200-clause AND chains compile in milliseconds; verified in the red-team corpus.
- 1000-element IN lists — verified to compile and execute in bounded time.
- SQL injection via identifiers — every column / table / alias name reaching the SQL emitter is double-quoted with embedded quotes doubled. Identifiers from the customer query are always validated against the policy allowlist before they reach the emitter; identifiers from the policy itself are dev-controlled.
- String literal injection — string literals are passed as
parameters (
?Nplaceholders), never concatenated into SQL. The test-only ClickHouse source inlines params as quote-doubled SQL literals; this path is safe because params here are policy-resolved values, not raw SQL strings.
- All comparisons, logical operators, and arithmetic route through
Spire.Engine.Null. Elixir's two-valued==/and/or/notare never used to evaluate SQL expressions. The behavior is proven exhaustively over{true, false, nil}intest/spire/engine/null_truth_tables_test.exs. WHERE NULL,WHERE x = NULL,WHERE NOT NULLall reject rows. Verified in the red-team corpus.
Spire.Engine.Null.term_compare/2is the engine's single three-way comparator. Every SQL compare op (sql_eq,sql_lt,sql_in,sql_between…) andSpire.Engine.Sort.compare_one/4go through it.- For same-type
Date/Time/DateTime/NaiveDateTime/Decimalvalues it dispatches toModule.compare/2— comparing by value, not by Erlang's struct-field-alphabetical term ordering. Without this dispatch,Decimal.new("10")sorted beforeDecimal.new("2.50")(term order on thecoeffield) and2024-12-31sorted after2025-01-01(term order ondayalphabetically beforeyear). - Integer/float values are lifted into
Decimalfor cross-type compare soWHERE amt > 5works against aDecimalcolumn. - Pinned by
test/spire/property/typed_compare_test.exs.
- Custom function crashes — functions passed via
Spire.query(..., functions: %{...})are user code. If they raise / throw / exit, the engine surfaces%Spire.Error{kind: :engine, code: :function_crashed, meta: %{function: name, reason: …}}rather than letting the exception escape past the engine Task. Pinned bytest/spire/property/function_crash_test.exs. - Arithmetic domain errors —
sql_pow(0, -1),sql_pow(-1, 0.5), and overflow returnnil(matchingsql_div(_, 0) → nil) instead of raisingArithmeticError. Pinned bytest/spire/property/pow_edge_cases_test.exs. - Engine row-materialization budget — sort / aggregate / join
are blocking ops that pull the full upstream into memory.
Policy.max_engine_rows(default1_000_000,:infinityinPolicy.permissive/0) caps it and surfaces%Spire.Error{code: :row_budget_exceeded}rather than OOMing the BEAM. - Runtime LIMIT / OFFSET bounds — negative param values
resolved through
LIMIT :nno longer slip pastStream.take(-1)(which silently returns the last row); rejected at plan time with%Spire.Error{code: :invalid_limit}.
Spire is intended to hold real customer data — names, emails, dates, monetary values. Every boundary where data might leak into Logger / telemetry / external observability is scrubbed:
params: %{s: :active}(an atom value, not a SQL type) raises%Spire.Error{kind: :param, code: :invalid_param_value}. The raise was added because the prior behavior silently mismatched every comparison and returned zero rows.- The error message and meta name the param key only (e.g.
"s"). The bound value is never echoed; only its Elixir type class (value_type: :atom).
[:spire, :query, :start | :stop | :exception],[:spire, :pushdown], and[:spire, :engine, :scan]events carry:source_module(the source struct's module name — an atom) but NEVER the source struct or the plan. The full source holds rows; the plan embeds literal values from the SQL (WHERE name = 'Alice'becomes%Lit{value: "Alice"}). Handlers commonly ship metadata to third-party services unchanged.[:spire, :engine, :materialize]has empty metadata; only:operationand:row_countmeasurements.[:spire, :query, :exception]still carries:kind,:reason,:stacktracefrom:telemetry.span/3itself — see "Handler hygiene" below.
- Token values for
:string,:int, and:floatliterals are reported as type only:expected '(', got string literal, notexpected '(', got string "alice@example.com".meta.gotfor literal tokens is{type, :redacted}. - Identifier and keyword token values still appear — they're SQL syntax / schema-public table and column names, and the user needs them to diagnose the error.
- Pinned by
test/spire/property/parser_no_pii_test.exs.
- A source's
scan/2orpushdown/3returning{:error, reason}surfaces only:reason_module(the reason struct's module name, if any) and a reason-class string ("map","tuple", etc.). Adapter exceptions like%Postgrex.Error{}carry the failed SQL with literals inlined plus bound params; the entire struct is no longer echoed into the engine error. - Pinned by
test/spire/property/source_error_no_pii_test.exs.
- A source returning
[%MyApp.User{}, …](the defaultEcto.Repo.all/1shape) used to crashnormalize_keys/1withProtocol.UndefinedError, AND the crash trace echoed the entire struct (including unused PII fields) to Logger. - Struct rows are now treated as bare maps with
__struct__and__meta__(Ecto's per-row schema metadata) stripped; other atom keys are converted to strings as for any bare map. - Pinned by
test/spire/property/struct_rows_test.exs.
:telemetry.span/3 emits its own standard fields on the
:exception event — :kind, :reason, :stacktrace — which
Spire does not control. If a custom function (passed via
Spire.query(..., functions: %{...})) raises with a PII-bearing
message, that message appears in :reason. Use a wrapping
handler if you forward telemetry to a third-party service and
need to redact further.
tenant_idbinding —enforced_where: tenant_id = :tenant_idinjects the predicate, but YOU choose what value to bind:tenant_idto. Pull it from your session / JWT / auth context; never from the customer's SQL or request body.- Policy completeness — a new table without a
Policy.Tableentry is automatically rejected (positive allowlist). But if you add a table to the policy and forget theenforced_where, that table returns all tenants' rows. The compiler can't infer scoping. columns: :allliterally means any column. Use explicit column lists for tables that might gain columns over time.policy: Spire.Policy.permissive()turns off the table/column/function allowlists and raises the complexity caps to:infinity. NEVER use it for SQL from end-users. It exists for dev-written SQL only (scripts, internal tools, code-generated SQL).require_limitis not on by default. Enable it for customer-facing endpoints; without it a customer can submit a query that returns the entire policy-scoped result set.
These are real attack categories Spire does not mitigate. Use infrastructure-layer controls.
- Timing side channels — a customer can craft a query whose
execution time reveals data (e.g., conditional
sleep-like patterns via heavy aggregates over filtered subsets). Spire'smax_joinsandmax_result_limitreduce surface area but don't eliminate it. - DoS via expensive-but-legal queries — a customer can submit a query that's policy-compliant but generates a massive ClickHouse / Postgres scan. Use database-side query timeouts, memory limits, and resource quotas.
- Connection / transaction exhaustion — Spire uses your repo's connection pool; ensure pool sizing and per-tenant rate limiting are appropriate.
- Information disclosure via errors — Spire's
Spire.ErrorandSpire.AnalyzerErrormessages are descriptive ("column X not allowed"). If you proxy these directly to customers, you may reveal the existence of columns / tables they didn't know about. For production, log the detailed error and return a generic one. - Network / transport security — outside Spire's scope; use TLS, network policies, etc. as normal.
Open a private GitHub Security Advisory on the repository. Include a reproducer (a SQL string, a policy, expected vs actual behavior). We aim to acknowledge within 48 hours.