Security Posture
August 15, 2026 · View on GitHub
What LibreDB Studio actually implements, and what it does not. Every row that claims a control
names the file that enforces it and the test that fails when it breaks; bun run security:check
runs in CI and fails the build when a row points at something that does not exist, or does not run,
or when a security test exists that no row accounts for.
For reporting a vulnerability, the disclosure timeline, supply-chain details and the SBOM, see SECURITY.md in the repository root. This page is the inventory; that one is the policy.
Scope
The deployment in scope is self-hosted, standalone Studio. There is one trust boundary and the operator is the owner. The adversary this list is built against is an unauthenticated attacker on the internet, because most of the distribution channels put the app on a public address.
Two consequences worth stating before the table:
- Running arbitrary SQL is the product's purpose, not a vulnerability. What is in scope is SQL the application composes itself — schema browsing, identifiers, pagination, filters.
- The browser copy of your credentials is not encrypted. See "Known limits" below. It is the reason the cross-site scripting controls are the highest-leverage entries in the table.
Controls
Notes on individual rows
0.1. The fix removed the HTML-string path rather than escaping its input, so a future edit to the markdown rules cannot reintroduce the sink. Fixed in 0.10.0; earlier releases are affected.
0.3. Exactly one route verifies something other than a user session, and it is named here rather
than left to the test file: POST /api/agent/drive, the callback that asks this server to pick an
agent run up again. It can have no session by construction — its caller is the durable transport,
not a person — so it verifies a credential this server minted instead: single-purpose, valid for a
minute, naming one run, signed with a key derived from JWT_SECRET rather than with JWT_SECRET
itself, so it is not a session and cannot become one. It grants nothing beyond continuing that run:
what the run may read is decided by the actor recorded in the run's own ledger, never by the
credential and never by the request body. src/proxy.ts's public-path list is unchanged — the
middleware admits this one path only when the credential verifies, and the handler verifies it
again.
1.1. The Content-Security-Policy permits inline scripts, because the application is statically
prerendered and its hydration scripts are inline and nonce-less. What the policy contains is
where an injected script could send data — not whether one can run. Set CSP_REPORT_ONLY=true
(a runtime variable, no rebuild) if an upgrade blocks a resource you need while you identify the
directive.
1.2. The counters live in the application process. With more than one replica the budgets apply
per replica; multi-replica deployments should enforce the same budgets at the ingress. See
charts/libredb-studio/README.md.
1.4. Marked Partial: sessions and origin failures are audited, role failures are not. Four
in-handler admin checks and the middleware's /admin redirect return their denial with no audit
line. Tracked in docs/BACKLOG.md, entry H12.
3.1. Applies to STORAGE_PROVIDER=sqlite and postgres only. Six fields are encrypted;
host, port, user, database and the TLS certificates stay readable so a dump can still be
identified. Rotating the key makes stored credentials unreadable — the connection survives, the
field is omitted. For STORAGE_PROVIDER=sqlite with no STORAGE_ENCRYPTION_KEY set, the fallback
key is persisted beside the SQLite file, in the same directory the Helm chart mounts as one volume
— a backup or snapshot of it carries the key alongside the ciphertext it opens. Set
STORAGE_ENCRYPTION_KEY from outside that volume (a Kubernetes Secret, an environment variable) to
close that gap; postgres deployments do not share this exposure by default. Full detail in
docs/STORAGE.md.
3.2. POST /api/admin/audit is the one writer that reaches the in-app buffer without reaching
stdout, and that is deliberate: its body is client-supplied, so giving it the authoritative channel
would let an admin session forge an indistinguishable log line.
3.4. WRITES are refused by the database itself — a PostgreSQL read-only transaction carrying
exactly one statement, run by a role verified at open to hold neither superuser nor any
server-file/program privilege (a read-only transaction does not stop COPY … TO PROGRAM); and a
separate SQLite read-only open with PRAGMA query_only re-asserted before every statement. Reading
the SQL is defense in depth only, never the boundary. A route now reaches this layer: an agent run
is opened at POST /api/agent/runs by a verified session, and every statement it sends passes
through the operation pipeline above, on a provider acquired for the run's read-only execution
profile rather than from the shared writable cache.
A second route reaches it, and it exists because the boundary above is the point.
POST /api/agent/runs/{runId}/handover replays the statement an auto-execute run answered with, in
the user's editor. It used to be replayed through POST /api/db/query — the ordinary editor path, a
read-WRITE session whose only protection is a syntactic read of the statement — which meant the same
text was refused where the run proved it and executed where the user saw it: a SELECT invoking a
VOLATILE function that performs an INSERT is refused by the read-only transaction (SQLSTATE 25006)
and performed by a read-write one, and no reading of the SQL can tell the two apart. The replay now
runs through provider.queryReadOnly under its own execution profile (agent-handover), so the
same database-native control applies to it. Its request carries no SQL at all — the statement comes
from the run's own answer-composed event and the connection from the run's persisted
connectionId — so it is not a general endpoint for running a statement read-only, and nothing a
user types reaches the profile.
Still Partial, for the one reason that
survives: out-of-scope READS have no database-native control on either provider — only the
declared-target allowlist, the statement guard, and whatever the role's grants bound (see
docs/BACKLOG.md A3).
3.5. Each execution emits a decision event and, once allowed, an execution-outcome event
sharing one server-generated correlation id; a denial emits the decision event alone with a typed
agent_* reason. The decision is recorded before the provider is called and the emission is not
wrapped in a try/catch, so an execution that cannot be audited does not run. What the events carry
is deliberately narrow: the registry-resolved operation id, an agent:<role> actor label, the
outcome, the reason code, the elapsed time and the correlation id — never the statement, the
agent-supplied operation id, the session identifier or a driver message. Results live in an
in-process artifact store released with the run (TTL and an entry cap are backstops), so no agent
result is written at rest. A user can read one of those results back while its run is live — the route
that serves it authorizes the run first and only then reads the store, keyed on the correlation id the
run's own ledger recorded, so an id belonging to another run answers exactly like one that never
existed; once the run ends, its results are gone and the route says so rather than reporting them
missing. One outcome carries no correlation id, because no execution produced one:
the agent tool layer refuses a call that no longer fits the run's wall-clock deadline BEFORE the
execution glue is reached, and records that with its own two agent_* reasons
(agent_run_deadline_exceeded, agent_insufficient_time_remaining) so a run that stopped on its own
deadline is not silent. The routes that make this reachable are session-verified in their own
handlers, and a run is authorized against the actor persisted in its own ledger — not against the
caller of the moment, so a drive that continues a run minutes later cannot widen what it may audit or
read (that resume path exists and authenticates, but nothing calls it yet — see
docs/BACKLOG.md B9). Still Partial, for the one reason that survives: the in-app
ring buffer is per-process — the stdout line remains the authoritative record.
Known limits
These are real, current, and not oversights. Each is a decision with a reason.
- Browser
localStorageholds your credentials in plaintext. It is the rendering source, and encrypting it would require a master password and a recovery flow, changing what the product is. This is why 0.1 and 1.1 matter as much as they do. - Anyone who can read the server's environment can read the stored credentials. 3.1 protects a
stolen database file or dump on its own; it is not a vault. For
STORAGE_PROVIDER=sqlitewith noSTORAGE_ENCRYPTION_KEYconfigured, that protection does not extend to a backup or volume snapshot of the data directory — see the note on 3.1 below. - A
usercan connect to any host and port and run any statement. The product ships two roles, and the boundary between them is not a policy engine. Target allowlists, per-provider command capabilities and a locked-down deployment profile are a coherent direction and are not implemented. - Local login credentials are not hashed. They arrive as
ADMIN_PASSWORDandUSER_PASSWORDenvironment variables, so the environment already holds the secret. Rate limiting (1.2) and the constant-time comparison (1.5) address the reachable part of the risk. - Rate limiting is per process and every bucket is keyed on something the caller supplies. See
docs/BACKLOG.md, entries H11 and H13. - Configuring an AI model means database content leaves the machine. Nothing here is telemetry
and nothing fires on its own, but an agent run sends the objective you typed, the schema
inventory, the relations graph and the rows of every read it performs to the model provider you
configured — and the three remaining AI routes send your statement and a schema context. The
agent fences everything database-derived before it reaches a prompt, and one path is not
fenced: an identifier the model quotes back into its own tool arguments
(
docs/BACKLOG.mdB29, open). What each surface sends, and what comes back, with the call site for every claim, isdocs/AGENT_DATA_FLOW.md. A key is not what decides whether any of this happens — a model configuration that validates is.validateConfigrequiresLLM_API_KEYfor thegeminiandopenaikinds only (src/lib/llm/utils/config.ts:127-134), so a keylessLLM_PROVIDER=ollamadeployment, or acustomone withLLM_API_URL, has the agent available and sends everything above to that endpoint. What sends nothing is a deployment with noLLM_*configuration at all: the provider defaults togemini(config.ts:12), it is refused without a key, availability answersNO_MODEL_CONFIGURED, no rail renders and no model call is made. - A test linked from this table is checked to exist and to run — not to be true. Nothing
verifies that a linked test actually exercises the control it is linked from. That is the
residual this page carries knowingly; the same limitation is recorded for the route-guard
allowlist in
docs/BACKLOG.md, entry H10. - No dynamic application security testing, no penetration test, no OpenSSF Scorecard badge yet. Each was deferred deliberately rather than skipped.
Verifying this page yourself
bun run security:check # the drift guard CI runs
bun run test # includes tests/security/
bun run test:e2e # includes the CSP verification against a real browser