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

IDControlStatusEnforced inVerified by
0.1LLM output never becomes an HTML string; the renderer builds React elementsImplementedsrc/components/DatabaseDocs.tsxtests/security/xss-sinks.test.tsx
0.2No remote origin can be fetched through the image optimizerImplementednext.config.tstests/security/image-proxy.test.ts
0.3Every route that reaches a database or an LLM verifies its caller in its own handler — a user session, or for the one machine callback a server-minted single-purpose credentialImplementedsrc/lib/api/require-session.ts, src/lib/agent/drive-token.tstests/security/route-auth.test.ts, tests/api/agent/drive.test.ts
0.4The security policy states only what the code doesImplementedSECURITY.mdtests/unit/security-check.test.ts
0.5A published reporting channel with a stated response timeImplementedSECURITY.mdtests/security/vulnerability-disclosure.test.ts
1.1Every response carries CSP, HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-PolicyImplementedsrc/lib/security/headers.ts, src/lib/security/config.ts, src/proxy.tstests/security/headers.test.ts, tests/security/header-delivery.test.ts, e2e/security-headers.spec.ts
1.2Login, AI and database-reaching routes are rate limitedImplementedsrc/lib/api/rate-limit.tstests/security/rate-limit-keying.test.ts, tests/security/rate-limit-routes.test.ts
1.3State-changing requests are checked against the deployment's own originImplementedsrc/lib/api/origin-check.ts, src/proxy.tstests/security/csrf-origin.test.ts
1.4Authentication transitions and denials are auditedPartialsrc/lib/audit.ts, src/lib/api/require-session.tstests/security/auth-audit.test.ts
1.5The login comparison is constant time and its failure response is uniformImplementedsrc/lib/auth-compare.ts, src/app/api/auth/login/route.tstests/security/login-enumeration.test.ts
2.1Secrets, dependencies and the container image are scanned in CIImplemented.github/workflows/security-scan.yml, .gitleaks.toml, .trivyignore.yamltests/unit/security-scan-workflow.test.ts, tests/unit/gitleaks-config.test.ts, tests/unit/trivyignore-policy.test.ts
2.2An SBOM is published with every releaseImplemented.github/workflows/release-artifacts.ymltests/unit/release-sbom.test.ts
2.3No TypeScript error is suppressed at build timeImplementednext.config.tstests/unit/next-config-typecheck.test.ts
3.1Credentials are encrypted at rest in the server-side storeImplementedsrc/lib/storage/encryption.ts, src/lib/storage/connection-secrets.ts, src/lib/storage/encrypting-provider.ts, src/lib/storage/factory.tstests/security/credential-at-rest.test.ts, tests/isolated/factory-singleton.test.ts, tests/integration/storage/sqlite-credential-encryption.test.ts
3.2Every authoritative (server-generated) audit event is emitted as one structured JSON line on stdoutImplementedsrc/lib/audit.tstests/security/audit-redaction.test.ts, tests/security/audit-type-safety.test.ts
3.3This page is checked against the repository on every buildImplementedscripts/security-check.mjstests/unit/security-check.test.ts
3.4A statement submitted on the agent execution path cannot write, change schema, reach another database, load code, or run the executing form of EXPLAINPartialsrc/lib/db/operations/policy.ts, src/lib/db/operations/statement-guard.ts, src/lib/agent/composed-sql.ts, src/lib/agent/tools.ts, src/lib/db/providers/sql/postgres.ts, src/lib/db/providers/sql/sqlite.ts, src/app/api/agent/runs/route.ts, src/app/api/agent/runs/[runId]/handover/route.ts, src/lib/agent/runtime.tstests/api/agent/handover.test.ts, tests/security/agent-statement-boundary.test.ts, tests/unit/lib/agent/composed-sql.test.ts, tests/unit/lib/agent/tools.test.ts, tests/api/agent/runs.test.ts, tests/integration/db/postgres-provider.test.ts, tests/integration/db/sqlite-provider.test.ts
3.5Every agent-path operation — allowed, denied, or held for approval — is audited under one correlation id, and its result is released with the runPartialsrc/lib/db/operations/execution.ts, src/lib/db/operations/artifacts.ts, src/lib/agent/tools.ts, src/lib/audit.ts, src/lib/api/agent-run-access.ts, src/app/api/agent/drive/route.ts, src/app/api/agent/runs/[runId]/artifacts/[correlationId]/route.tstests/security/agent-execution-audit.test.ts, tests/security/agent-tool-layer-audit.test.ts, tests/unit/db/operations/execution.test.ts, tests/unit/db/operations/artifacts.test.ts, tests/api/agent/drive.test.ts, tests/api/agent/artifacts.test.ts, tests/api/db/query.test.ts

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 localStorage holds 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=sqlite with no STORAGE_ENCRYPTION_KEY configured, that protection does not extend to a backup or volume snapshot of the data directory — see the note on 3.1 below.
  • A user can 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_PASSWORD and USER_PASSWORD environment 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.md B29, open). What each surface sends, and what comes back, with the call site for every claim, is docs/AGENT_DATA_FLOW.md. A key is not what decides whether any of this happens — a model configuration that validates is. validateConfig requires LLM_API_KEY for the gemini and openai kinds only (src/lib/llm/utils/config.ts:127-134), so a keyless LLM_PROVIDER=ollama deployment, or a custom one with LLM_API_URL, has the agent available and sends everything above to that endpoint. What sends nothing is a deployment with no LLM_* configuration at all: the provider defaults to gemini (config.ts:12), it is refused without a key, availability answers NO_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