Security Overview¶
SQLatte assumes every SQL statement it executes was generated by an LLM, not typed by a trusted operator — and validates accordingly, on every entry point (chat UI, embedded widgets, and MCP alike).
SQL Injection Protection¶
Two independent layers in src/core/sql_validator.py, run before the database is ever touched:
| Function | Purpose |
|---|---|
is_select_only(sql) |
Hard enforcement gate. Rejects anything that isn't a SELECT/WITH, any write keyword (INSERT, UPDATE, DELETE, DROP, TRUNCATE, ALTER, CREATE, GRANT, REVOKE, EXEC, CALL, MERGE, LOAD, COPY, …), and dangerous functions (pg_read_file, dblink, xp_cmdshell, lo_export, pg_sleep, http_get, EXTERNAL_QUERY, …) |
risk_score(sql) |
Observability only — never blocks. A 0–100 additive score logged to every audit record, regardless of whether the query was allowed |
String literals and comments are stripped before both checks, closing the obvious obfuscation bypass (hiding a keyword inside a quoted string or comment).
This applies identically to the main chat endpoint and the MCP/auth-widget endpoint — the bypass_intent flag MCP uses only skips intent classification (is this a data question or small talk), never SQL validation.
Risk score is additive: dangerous keywords add up to +50, dangerous functions up to +80 (weighted higher — they can exfiltrate data even inside a valid SELECT), and for queries that pass validation, exposure signals like SELECT * (+5), missing LIMIT (+5), UNION/INTERSECT (+10), deep subqueries (+5/level, max +15), and CROSS JOIN (+10) apply. A clean, limited SELECT scores 0; the ceiling for anything that actually executes is 45. See the Audit Logs page for how the score surfaces in practice.
Credential Isolation¶
Database credentials live server-side, in config.yaml or config_db, never in a client. MCP clients and embedded widgets authenticate with short-lived session tokens instead — see API Tokens & Admin Auth. Revoking access means invalidating a token, not rotating a shared password.
Field Masking (MCP)¶
Sensitive columns (emails, phone numbers, etc.) can be hashed, partially redacted, or fully redacted before results reach an MCP client — alias-aware, so an LLM can't rename a column to dodge the rule. See Field Masking.
Rate Limiting¶
Configurable per-endpoint request throttling (src/core/rate_limiter.py), three strategies:
rate_limiting:
enabled: true
strategy: sliding_window # sliding_window | token_bucket | fixed_window
key_type: session_id # session_id | ip | user_id
requests_per_window: 30
window_seconds: 60
protected_paths: ["/query", "/auth/query"]
exclude_paths: ["/health", "/static", "/admin"]
Limits are enforced in-memory per process and keyed by session, IP, or user depending on key_type.
Multi-Tenant Auth Plugin¶
The plugins.auth block enables per-user database connections with catalog/schema restrictions, so different users can be scoped to different parts of the warehouse from a single deployment:
plugins:
auth:
enabled: true
session_ttl_minutes: 480
allowed_catalogs:
- name: "hadoop"
allowed_schemas: ["hive"]
- name: "network"
allowed_schemas: ["cloudflare"]
allowed_db_types: ["trino"]
Admin login itself is separately brute-force protected (10 attempts / 5-minute window → 10-minute lockout per IP) and fails closed: if admin.password is unset, the admin panel refuses to authenticate anyone rather than falling back to an open door.
Login Types Comparison¶
| Access path | Credential held by client | Scope |
|---|---|---|
| Chat UI (default widget) | None — uses server config | Whatever the server's configured DB connection allows |
| Auth widget | Session token (via /auth/login) |
Per-user, restricted to allowed_catalogs/allowed_schemas |
| MCP (token mode) | API token | Same as the token owner's auth-widget session |
| MCP (legacy password mode) | Raw DB credentials in local config | Same as the DB user's own permissions |
| Admin panel | Admin session cookie | Full configuration access |