Skip to content

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