Skip to content

Latest commit

 

History

History
860 lines (773 loc) · 163 KB

SECURITY.md

File metadata and controls

860 lines (773 loc) · 163 KB

Security Posture

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

ID Control Status Enforced in Verified by
0.1 LLM output never becomes an HTML string; the renderer builds React elements Implemented src/components/DatabaseDocs.tsx tests/security/xss-sinks.test.tsx
0.2 No remote origin can be fetched through the image optimizer Implemented next.config.ts tests/security/image-proxy.test.ts
0.3 Every route that reaches a database or an LLM verifies its caller in its own handler: a user session, or for the two machine surfaces a server-minted credential of their own, the agent drive callback's single-purpose token and the MCP endpoint's scoped bearer token; in governance mode the MCP endpoint and every agent route answer as with MCP and the agent switched off, whatever the environment says, because this version runs neither under governance Implemented src/lib/api/require-session.ts, src/lib/agent/drive-token.ts, src/lib/mcp/bearer.ts, src/lib/mcp/token.ts, src/app/api/mcp/route.ts, src/lib/mcp/config.ts, src/lib/agent/config.ts tests/security/route-auth.test.ts, tests/api/agent/drive.test.ts, tests/security/mcp-auth.test.ts, tests/security/governance-holds.test.ts
0.4 The security policy states only what the code does Implemented SECURITY.md tests/unit/security-check.test.ts
0.5 A published reporting channel with a stated response time Implemented SECURITY.md tests/security/vulnerability-disclosure.test.ts
0.6 The host and port of an HTTP-based database connection address only that server and the transport's own paths, no such transport follows a redirect, and an opt-in address policy blocks non-public HTTP destinations and the destinations of RabbitMQ's AMQP dial; where a route takes a request's statement and parameters they travel in a body, never the URL query string: the InfluxDB query route is a form POST whose NodeRequest.form the shared transport serialises; a request's own headers on that transport are only the lower-case names its requestHeaderNames lists, never a name the transport or the connection sets, each value visible ASCII or space and at most 1024 bytes, and anything else is refused before any socket; the byte transport sends only GET and HEAD to a request path of unreserved characters, slashes and upper-case percent escapes and a query of name=value pairs of the same characters joined by &, never decodes a body, returns besides the content type, the content encoding and Retry-After only the response headers its connection selects and never Location or Set-Cookie, lets a request signer add only the header names it lists, holds its connection headers to the rule a request's own headers meet, and never reaches a link-local address or AWS's IPv6 instance metadata address (169.254.0.0/16, also in its IPv4-mapped and NAT64 64:ff9b::a9fe:0/112 forms, fe80::/10, fd00:ec2::254) whatever DB_HTTP_BLOCK_PRIVATE_HOSTS says; its request path is built by the caller and may carry . and .. segments, which it sends unresolved, so the server, or a proxy in front of it, decides which path on the configured origin they reach Implemented src/lib/db/http/endpoint.ts, src/lib/db/http/egress-policy.ts, src/lib/db/http/node-transport.ts, src/lib/db/providers/sql/clickhouse/http-transport.ts, src/lib/db/providers/sql/druid/http-transport.ts, src/lib/db/providers/sql/search/http-transport.ts, src/lib/db/providers/sql/trino/http-transport.ts, src/lib/db/providers/sql/libsql/hrana-transport.ts, src/lib/db/providers/document/couchbase/http-transport.ts, src/lib/db/providers/timeseries/prometheus/index.ts, src/lib/db/providers/timeseries/prometheus/request.ts, src/lib/db/providers/vector/qdrant/rest-client.ts, src/lib/db/providers/timeseries/influxdb/routes.ts, src/lib/db/providers/timeseries/influxdb/client.ts, src/lib/db/providers/sql/databend/routes.ts, src/lib/db/providers/sql/databend/http-transport.ts, src/lib/db/providers/sql/databend/connection-options.ts, src/lib/db/providers/stream/rabbitmq/amqp/dial.ts, src/lib/db/providers/sql/snowflake/account.ts, src/lib/db/providers/sql/snowflake/transport.ts tests/unit/db/http/endpoint.test.ts, tests/unit/db/http/egress-policy.test.ts, tests/unit/db/http/egress-policy-transport.test.ts, tests/unit/db/http/egress-policy-providers.test.ts, tests/unit/db/http/node-transport.test.ts, tests/unit/db/http/node-transport-tls.test.ts, tests/unit/db/http/node-transport-tls-material.test.ts, tests/unit/db/http/node-transport-guard.test.ts, tests/unit/db/http/node-transport-guard-lookup.test.ts, tests/unit/db/http/node-transport-runtimes.test.ts, tests/unit/db/http/node-transport-truncated.test.ts, tests/unit/db/http/node-transport-form.test.ts, tests/unit/db/http/node-transport-request-headers.test.ts, tests/unit/db/kafka/connection-options.test.ts, tests/unit/db/clickhouse/http-transport.test.ts, tests/unit/db/druid/http-transport.test.ts, tests/unit/db/search/http-transport.test.ts, tests/unit/db/trino/http-transport.test.ts, tests/unit/db/libsql/hrana-transport.test.ts, tests/unit/db/couchbase/http-transport.test.ts, tests/unit/db/prometheus/provider-endpoint.test.ts, tests/unit/db/prometheus/request.test.ts, tests/unit/db/qdrant/rest-client.test.ts, tests/unit/db/qdrant/rest-client-wire.test.ts, tests/unit/db/influxdb/routes.test.ts, tests/unit/db/influxdb/client.test.ts, tests/unit/db/influxdb/client-wire.test.ts, tests/unit/db/influxdb/seam-guard.test.ts, tests/unit/db/databend/routes.test.ts, tests/unit/db/databend/connection-options.test.ts, tests/unit/db/databend/http-transport-request.test.ts, tests/unit/db/databend/http-transport-links.test.ts, tests/unit/db/databend/wire.test.ts, tests/unit/db/databend/wire-tls.test.ts, tests/unit/db/databend/seam-guard.test.ts, tests/unit/db/http/endpoint-rfc3986.test.ts, tests/unit/db/http/egress-policy-link-local.test.ts, tests/unit/db/http/node-byte-transport.test.ts, tests/unit/db/http/node-byte-transport-guard.test.ts, tests/unit/db/rabbitmq/amqp/dial.test.ts, tests/unit/db/snowflake/account.test.ts, tests/unit/db/snowflake/transport.test.ts, tests/unit/db/snowflake/wire.test.ts, tests/unit/db/snowflake/seam-guard.test.ts, tests/unit/db/snowflake/security-control.test.ts
0.7 With ALLOW_CUSTOM_CONNECTIONS switched off, every route that builds a database provider opens only a connection the operator seeded: a connection the request supplies is refused with 403 before any provider is built Implemented src/lib/config/custom-connections.ts, src/lib/seed/resolve-connection.ts, src/app/api/admin/fleet-health/route.ts, src/app/api/connections/policy/route.ts tests/security/custom-connections.test.ts, tests/unit/seed/resolve-connection-custom-connections.test.ts, tests/api/admin/fleet-health.test.ts
0.8 Under governance a route opens a connection only when the session's identity provider subject holds an unexpired grant on it, with the credential of the grant's level, and the managed list shows only those, without a credential: an admin holds only its own grants, a connection that does not exist and one granted to someone else answer the same 404 CONNECTION_NOT_FOUND with no existence lookup and an audited permission_denied, a connection the request supplies is refused with 403 before any provider is built, and every connection route answers 503 GOVERNANCE_UNAVAILABLE while the governance snapshot cannot be served Implemented src/lib/governance/visible.ts, src/lib/seed/resolve-connection.ts, src/app/api/connections/managed/route.ts tests/security/governance-grants.test.ts, tests/unit/lib/governance/visible.test.ts, tests/unit/seed/resolve-connection.test.ts, tests/api/seed/managed-route.test.ts
0.9 Under governance every user of a connection runs on providers of their own, keyed by the session's identity provider subject: no other user's request shares their pool, their database session state or their interactive transaction, a cancel reaches only the statements their own provider runs, and a disconnect removes only their own providers Implemented src/lib/api/execution-context.ts, src/lib/db/provider-cache-key.ts, src/lib/db/factory.ts, src/lib/api/transaction-ownership.ts, src/app/api/db/disconnect/route.ts tests/security/governance-provider-isolation.test.ts, tests/isolated/factory.test.ts, tests/unit/lib/db/provider-cache-key.test.ts, tests/unit/lib/api/execution-context.test.ts, tests/api/db/transaction.test.ts, tests/api/db/cancel.test.ts, tests/api/db/disconnect.test.ts
1.1 Every document response carries CSP, HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy and Cross-Origin-Opener-Policy; the asset paths the middleware skips carry the two of those that act on a subresource; the Permissions-Policy denies publickey-credentials-get except while passkeys are ready on the server, and never lists publickey-credentials-create Implemented src/lib/security/headers.ts, src/lib/security/config.ts, src/proxy.ts tests/security/headers.test.ts, tests/security/header-delivery.test.ts, tests/security/cross-origin-headers.test.ts, e2e/security-headers.spec.ts
1.2 Login, AI and database-reaching routes are rate limited, and passkey sign-in spends a budget of its own, passkey_client, never the password login budgets Implemented src/lib/api/rate-limit.ts, src/lib/mcp/preprocess.ts, src/lib/api/login-budget.ts, src/app/api/auth/passkey/sign-in/route.ts tests/security/rate-limit-keying.test.ts, tests/security/rate-limit-routes.test.ts, tests/unit/lib/api/rate-limit.test.ts, tests/api/auth/passkey-sign-in.test.ts
1.3 State-changing requests are checked against the deployment's own origin, and every request to /api/mcp, whatever its method, against the MCP Origin allowlist, with its Host checked on a loopback bind Implemented src/lib/api/origin-check.ts, src/proxy.ts, src/lib/mcp/origin-policy.ts, src/app/api/mcp/route.ts tests/security/csrf-origin.test.ts, tests/security/mcp-origin-host.test.ts
1.4 Authentication transitions and denials are audited, passkey sign-ins, refusals and management among them, and the Audit tab shows each event's reason and passkey id Partial src/lib/audit.ts, src/lib/api/require-session.ts, src/lib/mcp/bearer.ts, src/lib/mcp/origin-policy.ts, src/proxy.ts, src/app/api/auth/passkey/sign-in/route.ts, src/lib/local-accounts.ts, src/lib/passkey/management.ts, src/components/admin/tabs/AuditTab.tsx tests/security/auth-audit.test.ts, tests/security/mcp-auth.test.ts, tests/security/mcp-origin-host.test.ts, tests/security/passkey-sign-in.test.ts, tests/security/passkey-registration.test.ts, tests/unit/lib/local-accounts-passkeys.test.ts, tests/components/admin/AuditTab.test.tsx
1.5 The login comparison is constant time and its failure response is uniform Implemented src/lib/auth-compare.ts, src/app/api/auth/login/route.ts tests/security/login-enumeration.test.ts
1.6 A local account with a TOTP secret configured cannot be signed in with its password alone, and an accepted code cannot be used twice Implemented src/lib/totp.ts, src/lib/local-auth.ts, src/app/api/auth/login/route.ts tests/security/mfa-second-factor.test.ts
1.7 A local account in the server store keeps its sessions and MCP tokens only while it is enabled and unchanged: disabling, deleting, a role change or a password reset ends them at their next request, the last enabled admin cannot be removed even by concurrent requests, and its own second factor changes only with its current password and code Implemented src/lib/auth.ts, src/lib/local-accounts.ts, src/lib/mcp/account-check.ts, src/app/api/auth/totp/route.ts, src/lib/storage/providers/postgres.ts, src/lib/storage/providers/sqlite.ts tests/security/mcp-account-revocation.test.ts, tests/api/admin/account-sessions.test.ts, tests/api/auth/totp-reauth.test.ts, tests/unit/lib/local-accounts-last-admin.test.ts
1.8 A passkey signs in only a local account in the server store, only from the configured origin, only with user verification, and at most once per server-issued challenge; adding or removing one needs the current password and code, an admin password set or break-glass removes it, it is removed with its account, and no account or passkey write lands over a change it did not read Implemented src/lib/passkey/config.ts, src/lib/passkey/ceremony.ts, src/lib/passkey/webauthn.ts, src/lib/passkey/sign-in.ts, src/lib/passkey/management.ts, src/lib/local-accounts.ts, src/lib/storage/providers/sqlite.ts, src/lib/storage/providers/postgres.ts tests/security/passkey-sign-in.test.ts, tests/security/passkey-registration.test.ts, tests/unit/lib/passkey/webauthn.test.ts, tests/unit/lib/local-accounts-passkeys.test.ts
1.9 A platform launch token signs in only while launch sign-in is configured and never under OIDC, only as an HS256 token typed libredb-launch+jwt and signed with this server's launch secret for its configured issuer and audience, issued for at most 60 seconds and presented once; it never replaces a session the browser holds for another account, and a browser with no session posts it only after the person confirms the account it names, in the server store it reaches only an account a launch created for the same platform identity and never one with a password, an authenticator or a passkey, it never signs in a disabled account or demotes the last enabled admin, and the token itself reaches no log line and no audit record Implemented src/lib/launch/config.ts, src/lib/launch/verify.ts, src/lib/launch/replay.ts, src/app/api/auth/launch/route.ts, src/lib/local-accounts.ts, src/proxy.ts tests/security/launch-token.test.ts, tests/unit/lib/launch/verify.test.ts, tests/unit/lib/local-accounts-launch.test.ts, tests/api/auth/launch.test.ts
1.10 Under governance a session reaches a route only when the OIDC callback minted it, or a native client's access token (1.11) stands for it, for the identity provider at OIDC_ISSUER with a non-empty subject, and only until the governance source revokes that subject: an env password, passkey or launch session, an OIDC session without a subject, one minted for another issuer and one issued at or before its subject's revoked_before in the snapshot data routes are served from are refused and their cookie cleared, a revocation row whose revoked_before cannot be read revokes every session of its subject, the OIDC callback mints no session the rule refuses, an MCP token is refused, and password sign-in answers 403 before it reads a credential Implemented src/lib/local-accounts.ts, src/lib/auth.ts, src/lib/governance/state.ts, src/lib/governance/snapshot.ts, src/lib/mcp/account-check.ts, src/app/api/auth/login/route.ts, src/app/api/auth/oidc/callback/route.ts, src/lib/governance/config.ts tests/security/governance-sign-in.test.ts, tests/unit/lib/local-accounts-governance.test.ts, tests/unit/lib/auth-bearer-session.test.ts, tests/unit/lib/governance/snapshot.test.ts, tests/api/auth/login.test.ts, tests/api/auth/oidc-callback.test.ts
1.11 An API call may stand on an identity provider access token instead of the session cookie only while OIDC_BEARER_AUDIENCE is set under OIDC: the token must be an asymmetrically signed JWT from the key set of OIDC_ISSUER, for that audience, unexpired, not an ID token and, with OIDC_BEARER_CLIENT_IDS set, from a listed client, and must name a subject, a username no other person could claim (an email not marked unverified, else a username that is not email-shaped, as an OIDC sign-in takes it too) and a role; a Bearer header then decides alone on an API path and on the public routes that read a session, a refused token answers 401 without a redirect and a refused account 403, both audited, a provider that cannot be reached answers 503, a page never takes one, a bearer session clears and sets no cookie, cannot mint an MCP token and receives no connection credential, and with the channel off the header is ignored outside /api/mcp Implemented src/lib/oidc-bearer.ts, src/lib/oidc-identity.ts, src/app/api/auth/oidc/callback/route.ts, src/proxy.ts, src/lib/auth.ts, src/app/api/auth/logout/route.ts, src/app/api/mcp/token/route.ts, src/app/api/connections/managed/route.ts, src/lib/config/auth-preflight.ts tests/unit/lib/oidc-bearer.test.ts, tests/unit/lib/oidc-identity.test.ts, tests/api/auth/oidc-callback.test.ts, tests/api/proxy-bearer.test.ts, tests/unit/lib/auth-bearer-session.test.ts, tests/api/auth/logout.test.ts, tests/api/mcp/token.test.ts, tests/api/seed/managed-route.test.ts, tests/unit/lib/config/auth-preflight.test.ts
2.1 Secrets, dependencies and the container image are scanned in CI Implemented .github/workflows/security-scan.yml, .gitleaks.toml, .trivyignore.yaml tests/unit/security-scan-workflow.test.ts, tests/unit/gitleaks-config.test.ts, tests/unit/trivyignore-policy.test.ts
2.2 An SBOM is published with every release Implemented .github/workflows/release-artifacts.yml tests/unit/release-sbom.test.ts
2.3 No TypeScript error is suppressed at build time Implemented next.config.ts tests/unit/next-config-typecheck.test.ts
3.1 Credentials are encrypted at rest in the server-side store, connection secrets and stored TOTP secrets alike; passkey rows hold public keys and metadata only and are not sealed, because none of them signs anything Implemented src/lib/storage/encryption.ts, src/lib/storage/connection-secrets.ts, src/lib/storage/encrypting-provider.ts, src/lib/storage/factory.ts tests/security/credential-at-rest.test.ts, tests/isolated/factory-singleton.test.ts, tests/integration/storage/sqlite-credential-encryption.test.ts, tests/unit/lib/storage/encrypting-provider.test.ts
3.2 Every authoritative (server-generated) audit event is emitted as one structured JSON line on stdout Implemented src/lib/audit.ts tests/security/audit-redaction.test.ts, tests/security/audit-type-safety.test.ts, tests/security/audit-channel-callsites.test.ts
3.3 This page is checked against the repository on every build Implemented scripts/security-check.mjs tests/unit/security-check.test.ts
3.4 A statement submitted on the agent execution path cannot write, change schema, reach another database, load code, or run the executing form of EXPLAIN Partial src/lib/db/operations/policy.ts, src/lib/db/operations/statement-guard.ts, src/lib/agent/composed-sql.ts, src/lib/agent/languages/sql/engines/, src/lib/agent/tools.ts, src/lib/db/providers/sql/postgres.ts, src/lib/db/providers/sql/sqlite.ts, src/lib/db/providers/sql/mssql.ts, src/lib/db/providers/sql/mysql.ts, src/lib/db/providers/document/mongodb/index.ts, src/lib/db/providers/document/mongodb/agent-boundary.ts, src/lib/db/providers/document/mongodb/agent-read.ts, src/lib/db/providers/document/mongodb/envelope.ts, src/lib/db/operations/mongo-admission.ts, src/lib/agent/languages/mongo/tools.ts, src/app/api/agent/runs/route.ts, src/app/api/agent/runs/[runId]/handover/route.ts, src/lib/agent/runtime.ts tests/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, tests/integration/db/mssql-provider.test.ts, tests/integration/db/mysql-provider.test.ts, tests/integration/db/mongodb-provider.test.ts, tests/unit/db/mongodb/agent-boundary.test.ts, tests/unit/db/mongodb/agent-read.test.ts, tests/unit/db/mongodb/envelope.test.ts, tests/unit/db/operations/mongo-admission.test.ts, tests/unit/lib/agent/mongo-tools.test.ts
3.5 Every agent-path operation — allowed, denied, or held for approval — is audited under one correlation id, and its result is released with the run Partial src/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.ts tests/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
3.6 Every application of an edited object definition is authorised by a server-issued plan, and both the decision and the engine's verdict are audited under one correlation id Implemented src/app/api/db/objects/edit-plan/route.ts, src/app/api/db/objects/edit-apply/route.ts, src/lib/api/object-edit-plan-token.ts, src/lib/db/object-edit.ts, src/lib/db/connection-fingerprint.ts tests/security/object-edit-audit.test.ts, tests/api/db/objects/edit-plan.test.ts, tests/api/db/objects/edit-apply.test.ts, tests/unit/lib/db/connection-fingerprint.test.ts
3.7 Every MCP tool call is audited under one correlation id, its decision before any provider is reached, and a call whose decision cannot be recorded does not run Implemented src/lib/mcp/audit.ts, src/lib/mcp/output.ts, src/lib/mcp/preprocess.ts, src/lib/mcp/tools/list-connections.ts, src/lib/mcp/tools/inspect-schema.ts, src/lib/mcp/tools/run-read-query.ts, src/lib/audit.ts tests/security/mcp-audit.test.ts, tests/integration/mcp/run-read-query.test.ts, tests/integration/mcp/inspect-schema.test.ts
3.8 A connection set read-only, and a provider an agent execution profile opens read-only, refuse every write command, value edit and maintenance operation before any request where the engine's provider keeps the mode, and readOnly: true is refused before anything is built or dialled on every other engine Partial src/lib/db/factory.ts, src/lib/db/compatibility.ts, src/lib/seed/types.ts, src/lib/seed/connection-filter.ts, src/lib/db/providers/keyvalue/etcd/write-policy.ts, src/lib/db/providers/vector/milvus/write-policy.ts, src/lib/db/providers/vector/qdrant/index.ts, src/lib/db/providers/timeseries/influxdb/influxql-policy.ts, src/lib/db/providers/timeseries/influxdb/sql-policy.ts, src/lib/db/providers/timeseries/influxdb/influxql-provider.ts, src/lib/db/providers/timeseries/influxdb/sql-provider.ts, src/lib/db/providers/keyvalue/oxia/guard.ts, src/lib/db/providers/keyvalue/oxia/grpc-client.ts, src/lib/db/providers/objectstore/s3/client.ts, src/lib/db/providers/objectstore/s3/console/guard.ts tests/unit/db/etcd/read-only-end-to-end.test.ts, tests/unit/db/etcd/write-policy.test.ts, tests/unit/db/read-only-enforced-capability.test.ts, tests/unit/seed/types.test.ts, tests/isolated/factory.test.ts, tests/unit/db/milvus/write-policy.test.ts, tests/unit/db/milvus/read-only-end-to-end.test.ts, tests/unit/db/qdrant/provider.test.ts, tests/unit/db/influxdb/influxql-provider.test.ts, tests/unit/db/influxdb/sql-provider.test.ts, tests/unit/db/oxia/read-only-end-to-end.test.ts, tests/unit/db/s3/read-only-end-to-end.test.ts
3.9 On an etcd connection, a write that touches a Kubernetes storage prefix or kube-apiserver's compact_rev_key is refused on the server before any write is sent, and a Kubernetes protobuf or encrypted value or a Kubernetes secret is never shown on any surface Implemented src/lib/db/providers/keyvalue/etcd/write-policy.ts, src/lib/db/providers/keyvalue/etcd/values.ts tests/unit/db/etcd/write-policy.test.ts, tests/unit/db/etcd/values.test.ts
3.10 On a Neo4j connection, a statement runs only when the read policy allows it, as one statement with no write clause, no LOAD CSV, no APOC or GDS name and only allowlisted procedures, qualified functions and SHOW forms, then only when the server classifies it a read, or s when it calls an allowlisted procedure, while an allowlisted SHOW form skips that classification (neo4j.md section 3.5), and then only in a READ session; a refused statement opens no session Implemented src/lib/db/graph/cypher/read-policy.ts, src/lib/db/providers/graph/neo4j/profile.ts, src/lib/db/providers/graph/neo4j/statement-gate.ts, src/lib/db/graph/graph-base-provider.ts tests/unit/db/graph/read-policy.test.ts, tests/unit/db/neo4j/statement-gate.test.ts, tests/unit/db/graph/graph-base-provider.test.ts
3.11 On a Qdrant connection, a request runs only when it is one of the seventeen read routes of the closed console, every body and filter key at every level is one its route declares, it carries no inference object except the local BM25 model and no options key, and it fits the console's bounds; a refused request is never sent and never written to history Implemented src/lib/db/providers/vector/qdrant/routes.ts, src/lib/db/providers/vector/qdrant/request.ts, src/lib/db/providers/vector/qdrant/guard.ts, src/lib/db/destructive-commands.ts tests/unit/db/qdrant/guard.test.ts, tests/unit/db/qdrant/request-phase0.test.ts, tests/unit/db/qdrant/request-phase1.test.ts, tests/unit/db/qdrant/request-filter.test.ts, tests/unit/db/qdrant/request-inference.test.ts, tests/unit/db/qdrant/request-budget.test.ts, tests/unit/db/qdrant/request-runtimes.test.ts, tests/hooks/use-query-execution.test.ts
3.12 On an InfluxDB (InfluxQL) connection, a statement runs only when the InfluxQL lexer, which reads text as the server's scanner does (a / starts a regex unless the token before it ends an operand), finds exactly one statement whose first keyword is SELECT, SHOW or EXPLAIN, no INTO keyword at any depth and no bound parameter, every character outside a quoted run, regex or comment is printable ASCII, and every quoted run, regex and comment is terminated and escaped as the server reads it; Flux text is refused; on InfluxDB 3 and an unknown version a statement that names the _internal database is refused; Studio-generated text passes the same policy; an answer holding anything but statement 0 is an error; a refused statement is never sent and never written to history Implemented src/lib/db/providers/timeseries/influxdb/influxql-lexer.ts, src/lib/db/providers/timeseries/influxdb/influxql-policy.ts, src/lib/db/providers/timeseries/influxdb/influxql-quote.ts, src/lib/db/providers/timeseries/influxdb/influxql-provider.ts, src/lib/db/providers/timeseries/influxdb/run-database.ts, src/lib/db/providers/timeseries/influxdb/routes.ts, src/lib/db/destructive-commands.ts tests/unit/db/influxdb/influxql-lexer.test.ts, tests/unit/db/influxdb/influxql-policy.test.ts, tests/unit/db/influxdb/influxql-differential.test.ts, tests/unit/db/influxdb/influxql-quote.test.ts, tests/unit/db/influxdb/influxql-provider.test.ts, tests/unit/db/influxdb/run-database.test.ts, tests/unit/db/influxdb/routes.test.ts, tests/hooks/use-query-execution.test.ts
3.13 On an InfluxDB 3 (SQL) connection, a statement runs only when it is one statement whose first keyword, after comments and opening parentheses read under the measured DataFusion grammar, is SELECT, WITH, VALUES, SHOW, EXPLAIN or DESCRIBE, only through a closed route table that reaches no write, configure, token, cache, plugin or engine route, and never against the _internal database Implemented src/lib/db/providers/timeseries/influxdb/sql-policy.ts, src/lib/db/providers/timeseries/influxdb/routes.ts, src/lib/db/providers/timeseries/influxdb/client.ts, src/lib/db/providers/timeseries/influxdb/sql-objects.ts, src/lib/db/providers/timeseries/influxdb/run-database.ts, src/lib/db/providers/timeseries/influxdb/sql-provider.ts, src/lib/sql/grammar.ts tests/unit/db/influxdb/sql-policy.test.ts, tests/unit/db/influxdb/routes.test.ts, tests/unit/db/influxdb/sql-objects.test.ts, tests/unit/db/influxdb/run-database.test.ts, tests/unit/db/influxdb/sql-provider.test.ts, tests/unit/sql/grammar.test.ts
3.14 On an Oxia connection, a shard leader is dialled only when its advertised address is byte-equal to the address the connection's bootstrap call was sent to, or is an exact entry of the connection's Data servers; every call's authority is the dialled host and port; every listed leader gets the connection's TLS mode, CA and client pair with its own host as TLS identity, and the plaintext-token refusal; a leader that fails the policy fails the operation before any call on it, and the token is never sent to it Implemented src/lib/db/providers/keyvalue/oxia/connection-options.ts, src/lib/db/providers/keyvalue/oxia/grpc-client.ts, src/lib/db/grpc/credentials.ts tests/unit/db/oxia/connection-options.test.ts, tests/unit/db/oxia/grpc-client.test.ts, tests/unit/db/oxia/cluster-policy.test.ts
3.15 On an Oxia connection, the client can call only GetShardAssignments, Read, List, RangeScan and the health check, every key under __oxia/ is refused before any call, internal keys are never requested, and the admin port is never dialled Implemented src/lib/db/providers/keyvalue/oxia/grpc-client.ts, src/lib/db/providers/keyvalue/oxia/commands.ts, src/lib/db/providers/keyvalue/oxia/guard.ts tests/unit/db/oxia/seam-guard.test.ts, tests/unit/db/oxia/grpc-client.test.ts, tests/unit/db/oxia/guard.test.ts
3.16 Opening a DuckDB connection, Test Connection included, makes no network request and loads no extension: every handle opens with extension autoinstall, autoload and community extensions off, and a file that is not a DuckDB database is refused, unopened and unchanged, before the engine sees it Implemented src/lib/db/providers/sql/duckdb/client.ts tests/integration/db/duckdb-provider.test.ts
3.17 A DuckDB editor handle opens with enable_external_access: 'false' for every non-admin role, and for every role on a DuckDB seed a non-admin role can use, so no statement reaches the network or a file other than the database's own files and the handle's private temp directory, while that database stays writable; this is statement-level only, and the database path itself is still the connection's; the posture is derived server-side from the session role and the resolved connection, never the request body, and an admin keeps full reach on an inline connection and an admin-only seed Implemented src/lib/db/providers/sql/duckdb/client.ts, src/lib/api/execution-context.ts, src/lib/db/factory.ts, src/lib/db/provider-cache-key.ts tests/integration/db/duckdb-provider.test.ts, tests/api/db/duckdb-file-access.test.ts, tests/api/db/duckdb-posture-routes.test.ts, tests/security/duckdb-posture-callsites.test.ts, tests/unit/lib/api/execution-context.test.ts
3.18 On an S3-compatible object storage connection, a console command runs only when it is one of the nine closed AWS CLI read commands (aws s3 ls, aws s3api list-buckets, aws s3api list-objects-v2, aws s3api list-object-versions, aws s3api head-bucket, aws s3api head-object, aws s3api get-object-tagging, aws s3api get-bucket-location, aws s3api get-bucket-versioning) or Studio's own preview, with only the flags its row declares, and it fits the console's text bound; the client sends only GET and HEAD requests, to ListBuckets, HeadBucket, GetBucketLocation, GetBucketVersioning, ListObjectsV2, ListObjectVersions, HeadObject, a ranged GetObject and GetObjectTagging; a command the editor refuses is never sent and never written to history; a command refused only on the server, because it names an endpoint, a region or a bucket other than the connection's, is not sent either, and history keeps its text whole with the refusal sentence as its error, as it does for every statement the editor accepts; and a key whose . and .. segments, resolved with empty segments skipped, leave its bucket or name the bucket itself is refused before any request wherever an object path is built, so the bucket pin holds under a server or proxy that resolves dot segments or merges slashes Implemented src/lib/db/providers/objectstore/s3/client.ts, src/lib/db/providers/objectstore/s3/console/commands.ts, src/lib/db/providers/objectstore/s3/console/guard.ts, src/lib/db/destructive-commands.ts, src/lib/db/providers/objectstore/s3/names.ts, src/lib/db/providers/objectstore/s3/encoding.ts tests/unit/db/s3/client.test.ts, tests/unit/db/s3/console-commands.test.ts, tests/unit/db/s3/console-guard.test.ts, tests/unit/db/s3/read-only-end-to-end.test.ts, tests/unit/db/s3/names.test.ts, tests/unit/db/s3/encoding.test.ts, tests/unit/db/s3/objects.test.ts
3.19 On an S3-compatible object storage connection, a request is signed only with the access key pair typed into the connection, and an empty pair sends it unsigned: no environment variable, shared credential or config file, container credential endpoint or instance metadata is ever read; an endpoint that is or resolves to an address in 169.254.0.0/16 (also in its IPv4-mapped and NAT64 forms) or fe80::/10, or to fd00:ec2::254, is refused before any connection by the byte transport (row 0.6), whatever DB_HTTP_BLOCK_PRIVATE_HOSTS says, and through an SSH tunnel the far end's address literal is checked the same way; a request is not sent over plain HTTP to a host that is neither loopback nor tunnelled unless the connection consents, whether or not it is signed; no object is read on a connection whose connect probe did not parse as S3 XML (ListObjectsV2 on the pinned bucket as ListBucketResult, else ListBuckets as ListAllMyBucketsResult); every XML answer is read by a reader that refuses a document type declaration and every entity; and the object preview hands a Parquet footer or page header to its decoder only when its Thrift encoding reads to the same values in the decoder as in Studio's check, refuses a footer whose sizes, counts or offsets are not non-negative safe integers or whose fields pass a fixed bound, parses each footer inside the two decode slots the decodes share, and bounds every decode and every rendered cell before the work, not after Implemented src/lib/db/providers/objectstore/s3/connection-options.ts, src/lib/db/providers/objectstore/s3/sigv4.ts, src/lib/db/providers/objectstore/s3/xml.ts, src/lib/db/providers/objectstore/s3/monitoring-reads.ts, src/lib/db/http/egress-policy.ts, src/lib/db/http/node-transport.ts, src/lib/db/providers/objectstore/s3/parquet-thrift-guard.ts, src/lib/db/providers/objectstore/s3/preview-parquet.ts, src/lib/db/providers/objectstore/s3/preview-cells.ts tests/unit/db/s3/connection-options.test.ts, tests/unit/db/s3/sigv4.test.ts, tests/unit/db/s3/xml.test.ts, tests/unit/db/s3/monitoring-reads.test.ts, tests/unit/db/s3/client-wire.test.ts, tests/unit/db/http/egress-policy-link-local.test.ts, tests/unit/db/s3/parquet-thrift-guard.test.ts, tests/unit/db/s3/preview-parquet.test.ts, tests/unit/db/s3/preview-cells.test.ts
3.20 On a Kubernetes connection, a request leaves only when the allowlist admits its method, its final path after the API path and its query: GET on discovery, health, the OpenAPI documents, collections and objects whose verbs discovery lists, and pod logs, and POST to the three self-review endpoints with a body Studio builds; every other method and subresource (exec, attach, portforward, proxy, serviceaccounts/token, eviction, binding) is refused before any socket, and every answer to a request for Secrets, and every object whose kind is Secret, loses its data, stringData and last-applied annotation before any surface reads it Implemented src/lib/db/providers/platform/kubernetes/request-policy.ts, src/lib/db/providers/platform/kubernetes/client.ts, src/lib/db/providers/platform/kubernetes/redaction.ts tests/unit/db/kubernetes/request-policy.test.ts, tests/unit/db/kubernetes/redaction.test.ts, tests/unit/db/kubernetes/console/refusals.test.ts, tests/integration/db/kubernetes-provider.test.ts
3.21 On a RabbitMQ connection, the stream read client can encode only the AMQP 0-9-1 handshake, channel.open, basic.qos, basic.consume with an x-stream-offset argument, basic.ack, basic.cancel-ok, the close methods and the heartbeat, and no basic.get, basic.reject, basic.nack, basic.recover, publish or declare, through one socket write site; the management client sends only GET to a closed route table that reaches no administrator endpoint and no route that takes a message, and refuses a name . or .. before any request; a read is sent only after the management API reports the object a stream, and the consume's x-stream-offset makes the broker refuse it on anything else; every inbound frame, header table and body is bounded before it is held, and every read ends within its deadline with its socket destroyed; the live read-only check, run in CI's Functional Smoke job, compares the broker's own take counters before and after a run of every provider surface and requires a control take to move them (rabbitmq.md section 14.3) Implemented src/lib/db/providers/stream/rabbitmq/amqp/codec.ts, src/lib/db/providers/stream/rabbitmq/amqp/connection.ts, src/lib/db/providers/stream/rabbitmq/amqp/frame-reader.ts, src/lib/db/providers/stream/rabbitmq/management/routes.ts, src/lib/db/providers/stream/rabbitmq/management/client.ts, src/lib/db/providers/stream/rabbitmq/read.ts tests/unit/db/rabbitmq/seam-guard.test.ts, tests/unit/db/rabbitmq/routes.test.ts, tests/unit/db/rabbitmq/read.test.ts, tests/unit/db/rabbitmq/hostile-peers.test.ts, tests/unit/db/rabbitmq/read-memory.test.ts, tests/integration/db/rabbitmq-provider.test.ts
3.22 On an Apache Spark connection, every request runs its statements in a Spark session of its own: a call with no request scope runs on a statement session released when its statement ends, and a request scope's session is released when the scope is ended, by Studio's query routes or, in the embedded package, by a host that passes a scope; a provider disconnect releases every session, and until then, or when a release cannot reach the server, a session can live until Spark's idle timeout; the provider's own metadata session runs no user statement; every session starts with variable substitution and Arrow compression off before its first statement; the token is never sent without TLS to a host that is neither loopback nor tunnelled; and every answer is bounded: before decoding, by a 16 MiB message cap with 4 MiB result chunks, a 32 MiB stream bound, and an Arrow stream check of framing, schema depth and size, buffer lengths and types that refuses compressed batches; while decoding, by a 100,000-row and 16 MiB statement budget in which each value's cost is known before the value is built and a Variant's text is bounded as it is written Implemented src/lib/db/providers/sql/spark/session.ts, src/lib/db/providers/sql/spark/connection-options.ts, src/lib/db/providers/sql/spark/grpc-client.ts, src/lib/db/providers/sql/spark/requests.ts, src/lib/db/providers/sql/spark/execute.ts, src/lib/db/providers/sql/spark/result-stream.ts, src/lib/db/providers/sql/spark/ipc-guard.ts, src/lib/db/providers/sql/spark/schema.ts, src/lib/db/providers/sql/spark/values.ts, src/lib/db/providers/sql/spark/variant.ts tests/unit/db/spark/session.test.ts, tests/unit/db/spark/connection-options.test.ts, tests/unit/db/spark/grpc-client.test.ts, tests/unit/db/spark/requests.test.ts, tests/unit/db/spark/result-stream.test.ts, tests/unit/db/spark/budgets.test.ts, tests/unit/db/spark/ipc-guard.test.ts, tests/unit/db/spark/schema.test.ts, tests/unit/db/spark/values.test.ts, tests/unit/db/spark/variant.test.ts
3.23 Under governance, Studio opens a sealed connection credential only with a private key of the keyring file GOVERNANCE_PRIVATE_KEYS_FILE names, under additional authenticated data that binds the envelope to its connection id, level, engine, key id and canonical definition, so an envelope moved to another connection, level, engine, host, port or TLS mode fails with AUTH_FAILED; the modules that open envelopes have no way to seal one; the server refuses to start when the keyring file cannot be read, is not JSON of version 1, lists no key or more than 16, lists a key that is not the unpadded base64url of 32 bytes, or lists two keys with one key id; an opened credential is cached under the SHA-256 of its envelope together with the additional authenticated data it opened under, so a cache hit never skips the binding check; and no refusal or log line carries key material or a credential, only key ids, positions and codes Implemented src/lib/governance/keyring.ts, src/lib/governance/secrets.ts, src/lib/governance/lsb1.ts, src/lib/governance/hpke.ts, src/lib/governance/preflight.ts tests/security/governance-sealed-secrets.test.ts, tests/unit/lib/governance/keyring.test.ts, tests/unit/lib/governance/secrets.test.ts, tests/unit/lib/governance/lsb1.test.ts, tests/unit/lib/governance/hpke.test.ts, tests/unit/lib/governance/preflight.test.ts
3.24 Under governance a query event row keeps of the statement only what the runtime setting queryLog.statementCapture allows, none unless the control plane sets another mode: under none no statement, fingerprint or error text; under redacted the statement with every literal, number and comment replaced by ?, a password written as a bare word included, and the SHA-256 of that text; under full the statement and the error text with the value after PASSWORD or IDENTIFIED BY and the userinfo of every URI masked; a refused request whose connection did not resolve keeps no statement or error text in any mode; and buildQueryEvent(), the only maker of rows, copies only the fields its input names, so a bind parameter value, a result row or a connection credential has no column to land in, whatever object a caller hands it Implemented src/lib/query-events/build.ts, src/lib/query-events/redact.ts, src/lib/query-events/types.ts tests/security/governance-query-events.test.ts, tests/unit/lib/query-events/build.test.ts, tests/unit/lib/query-events/redact.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. Two routes verify something other than a user session, and both are named here rather than left to the test files. POST /api/agent/drive is the callback that asks this server to pick an agent run up again, and its caller is the durable transport, not a person. It verifies a credential this server minted: single-purpose, valid for a minute, naming one run, and 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. /api/mcp is the MCP endpoint, which an AI client of the user's own calls with a token minted for that user on the settings screen. That token is signed with a key derived from JWT_SECRET under the configured LIBREDB_MCP_TOKEN_LABEL, so it is not a session either; it carries the role its owner had when it was minted, the mcp:read scope and the canonical LIBREDB_MCP_URL as its audience, and the session cookie opens nothing on that path. What a token may reach is decided again on every call, over the connections the seed file opts in for that role. A token minted for an account in the server store (STORAGE_PROVIDER=sqlite or postgres, local sign-in) carries that account's session version, and the route refuses it at the next call once the account is disabled, deleted, demoted or has its password reset, as the session itself is refused. For an env account or an OIDC identity there is no row to consult: such a token outlives a lowered role or a removed person until it expires or LIBREDB_MCP_TOKEN_LABEL changes, and changing the label, which revokes every token at once, is the only revocation. Minting needs a session signed in within the last ten minutes, so a person who can no longer sign in cannot mint under a rotated label once ten minutes have passed; offboarding such an identity is stopping the sign-in, then rotating the label after that window, or rotating JWT_SECRET, which also ends every session. src/proxy.ts's public-path list is unchanged: the middleware admits each of the two paths only when its credential verifies, and each handler verifies it again.

0.6. This is not a target allowlist: which server a connection reaches is still the user's choice (see "Known limits"). What it guarantees is that the value in the host and port fields is the whole of that choice: a host must be a hostname or an IP literal, a port an integer from 1 to 65535, and every URL is built with URL and checked after it is built, so no value can splice a path, a query or another authority into a request. A refusal names the field and never echoes the value. A 3xx answer is refused rather than followed, so neither the statement nor the credential goes to a server the connection does not name. Links a server hands back in a response body (Trino's nextUri chain) are held to the connection's own origin as well: a link on another scheme, host or port is refused before any request is sent to it. A signer passed to a byte transport is the one way a provider sets an authorization header on it: it is handed the method, Host, path and query and every header the transport sets, just before the request is written, and its output is merged after the transport's own checks; a header node:http adds on its own, such as Connection, is never shown to it; the transport does not read or verify the signature. The byte transport holds its connection headers to the rule a request's own headers meet, so they never carry authorization, a name the transport owns or a value that is not visible ASCII or space. Databend's next_uri is followed only when it is exactly the page or final path of the statement Studio sent, rebuilt from Studio's own query id, so an absolute URL, even one on the same origin, is refused. Two residuals are stated in the provider docs: libSQL's version probe answers "no version" on a redirect by contract, and Couchbase's TLS path reports a 3xx as an HTTP failure rather than as a redirect.

0.7. The switch is the operator's, and it is on unless the operator turns it off: ALLOW_CUSTOM_CONNECTIONS is true when unset, so a default deployment behaves as it always has (see "Known limits"). A value the parser does not recognise fails closed: after one pair of surrounding quotes is stripped, anything that is not true, 1, on, yes, false, 0, off or no switches custom connections off and logs an error naming the accepted values, so an operator who meant to switch them off never leaves them open by a typo. Switched off, the refusal is made in resolveConnection, the one function every database route resolves its connection through, and answers 403 with the code CUSTOM_CONNECTIONS_DISABLED, which a client tells apart from the role filter's 403 AUTH_ERROR; the admin fleet health check resolves each of its items through it too and reports a refused one as that item's error. A seed stays reachable whichever way it is named: by connectionId, or as an inline record whose id is seed:<id>, which is how an unmanaged seed's editable copy is sent and which the server has always resolved from the seed file, ignoring the copy's own fields. The agent runtime and the MCP endpoint never take a connection from the request, so the switch changes nothing there. GET /api/connections/policy tells the editor, which then hides New connection, Edit and Duplicate and lists seeds only; that is convenience, and the 403 is the control.

0.8. Governance mode (GOVERNANCE_SOURCE, docs/GOVERNANCE.md) makes the governance source the only catalog of connections, and a grant to the session's identity provider subject the only way to one. One dispatcher, src/lib/governance/visible.ts, answers both the managed list and resolveConnection, which every database route and the admin fleet health check resolve their connections through, so a route opens exactly what the list shows. It reads only the caller's own grants in the in-process snapshot and checks each grant's expires_at against Studio's clock at every decision, because expiry fires no notification; the role decides nothing, so an admin sees and opens only its own grants. A connection that does not exist, one granted to someone else, an expired grant and a row Studio did not admit, such as a PostgreSQL definition without a database, answer the same 404 CONNECTION_NOT_FOUND, and the seed lookup that tells the role filter's 403 from its 404 is never made. The miss is audited as permission_denied with reason connection_not_granted and the id that was asked for, metered on the anon bucket and keyed on the account like the role denials of 1.4, so a session probing ids in a loop cannot flood the log. A read grant opens the connection's read credential and a write grant its write credential, so what a read grant can do is decided by the database's own permissions for that credential; the managed list carries neither credential and neither server-only marker. While the snapshot cannot be served, because it is still loading, too old, misconfigured or incompatible, every connection route and the list answer 503 GOVERNANCE_UNAVAILABLE, never an empty list; a connection the request supplies is refused by 0.7 whatever the snapshot's state. The agent and MCP do not run under governance in this version (0.3). Two users granted the same level on one connection open it with the same credential, and 0.9 keeps their providers apart.

0.9. Without governance every user of a connection shares its provider: one pool, one database session state, such as a SET value or a temporary table, and one interactive transaction, bounded by the database credential and nothing else. Under governance the execution context of every editor request names the session's identity provider subject as the owner of the provider it runs on, and the provider cache key, the provider's pool and the transaction key all carry that owner, so two users who open one connection with the same credential never share a provider. An owned provider opens its pool with no idle connection and at most as many connections as the runtime setting provider.poolMaxPerUser allows, 3 by default, and a governed session without a subject is refused rather than handed the provider every user shares. A cancel is sent through the caller's own provider, so a query id that another user's statement runs under answers cancelled: false and stops nothing. POST /api/db/disconnect removes only the caller's own providers of the connection it names, editor and agent alike, so a request naming a connection another user has open closes nothing of theirs. An engine that keeps one handle per file, such as DuckDB, keeps that one handle for every owner, and governance admits no engine that opens a local file. Without governance every user of a connection still shares its provider, and a disconnect removes only the providers cached under the key of the caller's own record, the editor's provider of that record and any agent provider opened from it: it resolves a connection of the user's own from the record the request carries, the key frames that record's server and credentials, and the connection's SSH tunnel closes only once no provider of it is left. So a request naming another user's connection id closes neither their provider nor their tunnel, and a seed: id removes nothing, because one provider serves every user of that seed and the idle sweep reclaims it.

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.1, HSTS. Strict-Transport-Security is sent with a 180-day max-age (15 552 000 s) and has no off switch: a browser that has cached the pin keeps enforcing HTTPS-only until it expires, whatever the server sends next. Browsers ignore the header when it arrives over plain HTTP. includeSubDomains is off unless HSTS_INCLUDE_SUBDOMAINS=true, and it is left to the operator because its reach is a property of the hostname rather than of Studio: it extends the pin to every host below the one serving Studio, for the full 180 days, with no server-side way to withdraw it. On a dedicated host such as studio.example.com that usually covers nothing else; served from a registrable domain that other services sit under, it makes each of those services HTTPS-only in every browser that visited Studio. See .env.example under Security Headers.

1.1, the subresource half. The row says document response for a reason: the two delivery paths do not carry the same set. src/proxy.ts's matcher deliberately skips _next/static, _next/image and every path containing a dot — so a file under public/ or /monaco/vs/ never reaches it. next.config.ts covers those, and it carries two of the seven headers rather than all seven. That is not an omission: the set it may carry is derived from two exclusions, both now executed rather than asserted. A header whose value or presence changes under any option src/lib/security/config.ts can vary is operator-configurable and cannot be baked into a build-time routes manifest (the CSP, whose CSP_REPORT_ONLY escape hatch a baked copy would strand, and HSTS, which is host-scoped so the last value the browser sees wins). A header that acts on a document is inert on a subresource (Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy).

Selecting by exclusion reversed the default that AU2's allowlist had: a new constant document header is now baked onto /logo.svg and every _next/static chunk unless somebody excludes it, where before it was withheld until somebody opted it in. What replaces the closed default is tests/security/cross-origin-headers.test.ts — the one verifier in the row that is about the set rather than about a path or a value. It requires every header securityHeaders() sends to declare one of three reasons (baked, document-only, option-dependent) and then checks the declared reason against the class the two exclusions actually produce, so a header added with no decision recorded fails there. The other three verifiers answer different questions: headers.test.ts the CSP's meaning, header-delivery.test.ts what each path receives from each of the two deliveries, and security-headers.spec.ts whether the enforced policy breaks the real asset surface in a real browser.

1.1, the two cross-origin headers. Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy are the pair that would add protection a subresource can use, and they are answered differently: COOP is sent, CORP is refused.

COOP is same-origin, set per request in src/lib/security/headers.ts and applied by src/proxy.ts — the document path only. It is classified document-only in next.config.ts and therefore barred from the baked set: HTML's "create navigation params by fetching" obtains a response's opener policy under one step, "If navigable is a top-level traversable", so nothing ever reads the header off a subresource and it is ignored even for a framed document. Its value is a constant, which is exactly what makes baking it beside nosniff tempting and pointless. same-origin rather than the weaker same-origin-allow-popups, because the weaker value exists for a page that opens a popup and then scripts it and nothing here does: the OIDC flow is a top-level redirect in both directions (window.location.href = "/api/auth/oidc/login", and the logout redirect in src/hooks/use-auth.ts), and the one window.open call already passes "noopener,noreferrer". Three things to know when reading a header dump. COOP is ignored outright in a non-secure context — "obtain an opener policy" returns the default unsafe-none before it looks at the header — so on the plain-HTTP channels it is a no-op regardless (loopback counts as secure, so the desktop shell is not among them). It appears on documents and not on /monaco/vs/* or /logo.svg, by design. And same-origin without Cross-Origin-Embedder-Policy does not make the page cross-origin isolated — no SharedArrayBuffer, and crossOriginIsolated stays false. That pairing was declined deliberately: require-corp makes every cross-origin subresource that carries no CORP header of its own count as blocked, which the documented off-origin NEXT_PUBLIC_MONACO_VS_PATH setup cannot promise, and the capability it would buy is one nothing here uses.

CORP is refused, and the reason is recorded beside the header rule in next.config.ts. It could not break the editor — the cross-origin resource policy check is only reached for a response whose tainting is opaque, which a same-origin request never becomes, and under the shipped defaults every asset Monaco needs is same-origin. NEXT_PUBLIC_MONACO_VS_PATH is the one documented way that stops being true, and it changes nothing here: a CORP header of ours never labels another origin's responses. What refuses it is that its correct value is the only one in the set that is a property of the deployment topology rather than of the application, while the only delivery path that reaches a subresource is the one baked at build time. The protection given up is narrow, and it is read off this repository's own code: a cross-site no-cors subresource request already arrives without auth-token (sameSite: "lax"), nosniff already refuses to execute a non-JavaScript response at a script destination, and CORP is not a framing defence so it does not overlap X-Frame-Options. What stays open is the same-site, cross-origin case — a sibling subdomain under the operator's own registrable domain does get the cookie and can time an authenticated endpoint. That residual is stated under "Known limits" below.

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.2, tuning the budgets. Each bucket takes a request count and a window in seconds from a pair of runtime variables, documented in .env.example under Rate Limiting:

What it bounds Variables Default
Failed logins per client address RATE_LIMIT_LOGIN_MAX, RATE_LIMIT_LOGIN_WINDOW_SEC 5 per 300 s
Failed logins per submitted account RATE_LIMIT_LOGIN_ACCOUNT_MAX, RATE_LIMIT_LOGIN_ACCOUNT_WINDOW_SEC 20 per 300 s
Failed passkey sign-ins per client address (passkey_client) RATE_LIMIT_PASSKEY_MAX, RATE_LIMIT_PASSKEY_WINDOW_SEC 10 per 300 s
AI and agent-run requests per signed-in user RATE_LIMIT_AI_MAX, RATE_LIMIT_AI_WINDOW_SEC 20 per 60 s
Database-reaching requests per signed-in user RATE_LIMIT_QUERY_MAX, RATE_LIMIT_QUERY_WINDOW_SEC 120 per 60 s
Permission-denied audit lines RATE_LIMIT_ANON_MAX, RATE_LIMIT_ANON_WINDOW_SEC 5 per 300 s

Setting a *_MAX to 0 disables that bucket. A window below one second is raised to one. The last bucket bounds how often a refusal is written to the audit log; it never changes whether a request is refused.

Still Partial, for the one reason that survives: the proxy's jwtVerify failure arm is logged, not audited. A forged, tampered or truncated auth-token reaches the trailing catch in src/proxy.ts that covers the whole verifyToken call, which writes logger.warn("JWT verification failed, refusing the session") and refuses (a redirect to /login for a page, a 401 AUTH_REQUIRED for an API path) without an emitAuditEvent call, so the attempt lands in stdout and never in GET /api/admin/audit. The proxy's other two refusals both emit: origin_mismatch and insufficient_role. See docs/BACKLOG.md H12.

Everything the row once described short of that is audited. Role failures are recorded at all twelve sites that refuse on role, eleven in handlers and one in the proxy, and the eleven handler sites are the ones the Admin Audit tab can read:

site call
GET in src/app/api/admin/audit/route.ts auditRoleDenial
POST in src/app/api/admin/audit/route.ts auditRoleDenial
src/app/api/admin/accounts/route.ts auditRoleDenial
src/app/api/admin/accounts/[email]/route.ts auditRoleDenial
src/app/api/db/maintenance/route.ts auditRoleDenial
src/app/api/db/maintenance/preview/route.ts auditRoleDenial
src/app/api/db/monitoring/route.ts auditRoleDenial
src/app/api/admin/fleet-health/route.ts auditRoleDenial
src/app/api/admin/discovery/route.ts auditRoleDenial
src/app/api/admin/governance/route.ts auditRoleDenial
src/app/api/admin/seed-sources/route.ts auditRoleDenial
src/proxy.ts emitAuditEvent, insufficient_role

auditRoleDenial (src/lib/api/require-session.ts) emits permission_denied with reason: "insufficient_role", so those eleven reach the tab. An earlier version of this note claimed the opposite and pointed at an H12 that did not exist.

Two qualifiers the grade rests on, both deliberate and documented at each call site:

  • Recorded, not unlimited. Every permission_denied emit is metered through the anon bucket (the rate-limit table under 1.2), so a sustained denial is audited up to 5 lines per 300 s per key. The refusals themselves are never metered, and a missing token redirecting to /login (or answering 401 on an API path) is ordinary logged-out traffic, not a denial.
  • The proxy's ring is its own. src/proxy.ts compiles as a separate Next entry with its own module graph, so its audit writes do not appear in what GET /api/admin/audit returns. Mechanism and disclosure are 3.2's subject.

1.6. Opt-in: a second factor exists for an account exactly when ADMIN_TOTP_SECRET / USER_TOTP_SECRET is set, so the row claims nothing about a deployment that sets neither. Verification is RFC 6238 with HMAC-SHA-1, six digits, a 30-second step and one step of skew either side. Three things are worth stating precisely.

It guards POST /api/auth/login, and that route is not disabled by NEXT_PUBLIC_AUTH_PROVIDER=oidc — the provider setting changes what the login page renders, not what the API accepts. A deployment that runs OIDC while still setting ADMIN_PASSWORD therefore keeps a way in that never reaches the issuer, and so never meets the MFA policy configured there; these variables are honoured in that mode too, which is how it is closed (docs/MFA.md, docs/OIDC.md).

Replies distinguish "code required" from "invalid code", which is not the enumeration oracle 1.5 removes: both are reachable only with a correct password, and without MFA that same request would have returned a session.

The spent-code set that makes an accepted code single-use lives in the application process, like the counters in 1.2 — with more than one replica each process enforces its own view, so a captured code can be replayed once per replica inside its 90-second window.

1.8. Passkeys exist only for local accounts in the server store (STORAGE_PROVIDER=sqlite or postgres, local sign-in) and only while PASSKEY_ORIGIN names a valid origin; every other deployment offers no passkey at all, and an OIDC session is refused before any account is touched. The expected origin and RP ID come from that one variable, never from Host, X-Forwarded-Host or the request URL, and the client data's origin must equal it exactly, with no cross-origin or top-origin use. Full operator guide: docs/PASSKEYS.md.

The ceremony binding. The server draws a 32-byte challenge per ceremony and signs it into a token under a key derived from JWT_SECRET, so the token never verifies as a session nor a session as a token. The token travels in its own HttpOnly, SameSite=Strict cookie scoped to the passkey API path, one name per purpose, and lives 600 seconds; the verify request takes it whatever the outcome. That cookie binds the challenge to the browser that started the ceremony, which is the login CSRF defence, and a registration token also names the account and the session version it was confirmed under. The transaction that completes a ceremony records the challenge's hash in a spent set, so a second success with the same challenge fails on every replica; a spent row is kept 600 seconds past its token's expiry, so replicas whose clocks differ by up to that much still find it. No anonymous request writes a passkey or challenge row unless an assertion verifies; the first request to an empty account registry seeds it from the environment accounts, on this path as on the password one.

User verification, and how it reads against NIST SP 800-63B-4. Both ceremonies ask for user verification as required and both verifications require it, so every stored passkey was created with it. A verified passkey sign-in is a complete sign-in: it never asks for the password or the TOTP code, on an account with or without TOTP. NIST treats a WebAuthn authenticator that verified its user as a multi-factor cryptographic authenticator (AAL2, and a synced one stays AAL2), and one that did not as single-factor, which is why an assertion without user verification is refused on every account rather than accepted beside the password.

The counter policy. The library's own counter check runs before the signature and only throws, so Studio passes it a counter of 0 and applies the WebAuthn rule itself after the signature verified, in one guarded update inside the store transaction. A counter that did not increase is refused and audited as passkey_counter; the account is not locked and the stored counter does not change. Authenticators that always report 0, as synced passkeys do, are accepted.

Reauthentication, and the one deliberate deviation. Adding or removing a passkey needs the current password in the same request, and a current code when the account has TOTP; wrong answers are audited and spend both login budgets of 1.2. Removing a passkey ends the account's other sessions and MCP tokens. NIST 4.1.2.1 would require an account that has a passkey but no TOTP to prove an existing passkey before binding another, and Studio accepts the password there. Every Studio account keeps password sign-in, so its effective assurance is already its password, and demanding a passkey would protect nothing the password does not already reach. The residual, a stolen password buying a passkey that survives the recovery, is closed by the recovery itself: an admin password set removes the account's passkeys unless the admin keeps them, and ADMIN_PASSWORD_RESET always does.

Account writes. Every account write carries the version it read and applies only while the row still has it, and a write that touches an account and its passkeys changes or locks the account row first. A registration completes only while the account's session version equals the one its ceremony was confirmed under, and a removal only while it equals the caller's, so neither can land after an admin disable, role change, password set or passkey clear it did not see.

1.9. With local sign-in, launch sign-in exists only while LAUNCH_TOKEN_SECRET is set; until then POST /api/auth/launch answers 404 and nothing else changes. A secret under 32 characters, a missing LAUNCH_TOKEN_AUDIENCE or LAUNCH_TOKEN_ISSUER, or a secret equal to the key that signs sessions (JWT_SECRET, or its development fallback while it is unset), with which a launch token would pass as a session, is reported on that route as a 503 and logged once per problem, never as a failed boot. With NEXT_PUBLIC_AUTH_PROVIDER=oidc launch sign-in is not available whatever the launch variables say: the route and the /launch page answer 503 with Launch sign-in is not available when NEXT_PUBLIC_AUTH_PROVIDER=oidc., because in that mode no session is checked against the registry, so a disable or a role change would never reach a launched session. Full operator guide: docs/LAUNCH.md.

The token. The header's typ must be exactly libredb-launch+jwt and is checked before the signature, so no other kind of JWT, such as a Studio session, is ever taken for a launch token (explicit typing, RFC 8725 section 3.11). The algorithm is pinned to HS256 and the key is the UTF-8 bytes of the secret, so a header naming none or another algorithm is refused before any signature or claim is read. The issuer and audience must equal the configured values exactly, the token may be valid for at most 60 seconds from its iat, and 5 seconds of clock difference are tolerated in each direction. Its jti is remembered in the process until the token could no longer verify, its expiry plus the 5 seconds, so a second presentation is refused once the first one verified; with more than one replica each process remembers only what it accepted, the bound 1.6 states for TOTP codes. A process remembers at most 4096 such tokens, and while it holds that many a new launch is refused with a 503 until they expire, so no used token is forgotten early and made replayable. The token travels in the URL fragment of /launch, which a browser never sends to a server, and the page removes it from the address bar before it posts it to the route on its own origin. A browser with no Studio session posts it only after the person clicks Continue on a page that names the account the token signs into, read from the token for display only; a browser with a session posts it at once, so the route can apply the session rule below. On a plain-HTTP address the token crosses the network in that POST body unencrypted, as the session cookie does, and single use only narrows the window, so serve Studio over HTTPS. Every refusal and every malformed body spends the login_client budget of 1.2, which is checked before the body is read.

The session. A launch never replaces a valid session the browser holds for another username: the route answers 409 naming both accounts, keeps that session and spends the token, so a launch link someone else sends cannot swap a signed-in browser into their account, where what the person then saves would land. The page offers a sign-out, after which a fresh launch from the platform signs the other account in; a session for the same username is simply replaced. A browser that holds no session is not signed in by the link alone: the page names the account the link signs into and posts the token only on a Continue click, so a link someone else minted for their own account cannot sign a person in as them unseen (login CSRF). A person who clicks Continue for another email is still signed in as it, which docs/LAUNCH.md states among what launch sign-in does not protect.

The account. The platform that signs the token is trusted to have authenticated the person: a launch never asks for the account's password or authenticator code, as a verified passkey does not, and so it never reaches an account that has one. In the server store a launch creates an account for an email that has none, bound to the token's issuer and subject, with the token's role and no password, and signs in again only to an account bound to the same issuer and subject, matched by email without regard to case. An account with a password, an authenticator or a passkey, and an account bound to another platform identity are refused, and so is the ADMIN_EMAIL address whether or not its account exists, so a launch can neither take over an existing account nor hand an email the platform reassigned the previous owner's account. An admin who sets a password on a launched account ends launch sign-in for it, and adding an authenticator or a passkey asks for the current password, which a launched account does not have. A bound account whose role differs from the token's moves to it, which ends its other sessions as an admin's role change does; a disabled account is refused and stays disabled, and a launch that would demote the last enabled admin is refused, so no launch revives an account an admin disabled or leaves the registry without an enabled admin. A launched session lasts up to 24 hours and the account stays enabled when the platform later removes the person, so disabling the account, which ends its sessions at their next request (1.7), is what cuts that access at once. With STORAGE_PROVIDER=local there is no registry to consult: the ADMIN_EMAIL address, and USER_EMAIL while USER_PASSWORD is set, sign in with a password and are refused, and any other email gets a session that carries the token's email and role.

1.10. Governance mode (GOVERNANCE_SOURCE, docs/GOVERNANCE.md) makes the identity provider the only way in. getSession() applies the rule on every request through storedAccountAllows(), before any account store is opened and whatever NEXT_PUBLIC_AUTH_PROVIDER says, so it holds even where the boot check that refuses passwords and launch secrets was bypassed. A session counts only when it carries a non-empty idpSubject and an idpIssuer equal to OIDC_ISSUER, the two compared as URLs with one trailing slash ignored. Only the OIDC callback and a native client's access token (1.11) write those claims, so a password, passkey or launch session is refused, and with OIDC_ISSUER unset or not a URL every session is. An access token's session is held to the same rule with the access token's own iat, and a refused one clears no cookie, because it never set one. A refused session's cookie is cleared on the response, so the next navigation reaches the sign-in page; the proxy checks only the signature, so a page still loads for it, and its first API call answers 401. The OIDC callback applies the same rule to the ID token before it mints a session, because openid-client hands over an empty sub, and for Microsoft Entra ID and Azure AD B2C an iss other than OIDC_ISSUER: such a sign-in ends on /login?error=oidc_config instead of in a session every request refuses, which OIDC_AUTO_REDIRECT would replace with the next sign-in in a loop. POST /api/auth/login answers 403 before it reads the body and records login_failure with reason password_login_disabled; each refusal spends the login_client budget of 1.2, so a flood is refused with 429 and its audit lines stay bounded. An MCP token names no identity provider subject in this version, so its owner check refuses it, and the boot check refuses LIBREDB_MCP_ENABLED under governance as well. A control plane ends a person's sessions with a row in session_revocations_v1: storedAccountAllows() refuses a session whose token's iat, in milliseconds, is at or before its subject's latest revoked_before, and the change notification reloads the snapshot, so the next request answers 401, typically within a second of the commit. The token's iat counts whole seconds, so a session signed within the second of revoked_before counts as issued before it and is refused too, and a token without an iat counts as issued before every revocation of its subject. A revocation only ever denies, so the loader fails closed where a session can be matched: a row whose revoked_before it cannot read revokes every session of that subject, as +Infinity would, and a row without a readable subject, which no session can match, is only counted; both are logged at error level, and neither fails the load, which would hold back every other revocation until the snapshot went stale. Only a snapshot that data routes are served from revokes a session: while none has loaded, or the one kept is older than GOVERNANCE_MAX_STALENESS_MS, misconfigured or incompatible, every connection route answers 503 and no session is refused for a revocation, so a database outage sets off no wave of sign-ins and opens no connection for a revoked session. The check reads the in-process governance state only, through modules that import no pg, so the session check opens no database connection. The residual is timing and scope: a revocation ends a session once a snapshot that holds it is served, so a lost notification delays it by up to GOVERNANCE_REFRESH_MS; Studio dates a session by its own clock while the control plane dates a revocation by its own, so a Studio clock running ahead lets a session issued just before a revocation through; and while no snapshot can be served, a revoked session still reaches the routes that open no connection, such as its own workspace storage.

1.11. Bearer access tokens for native clients (docs/OIDC.md) are off unless OIDC_BEARER_AUDIENCE is set, and that variable without NEXT_PUBLIC_AUTH_PROVIDER=oidc, or equal to OIDC_CLIENT_ID, is refused at boot and again on every bearer request, because an embedded Studio runs no boot check. The proxy and getSession() both verify the token through one module that reaches no governance state, store or pg, so the proxy's boundary of 1.10 holds; the proxy is an optimisation and every route verifies again. Studio's own ID tokens are kept out by four tests at once: an aud that contains OIDC_CLIENT_ID, the ID-token claims nonce, at_hash and c_hash, Keycloak's typ claim ID, and a typ header other than none, JWT or at+jwt. With OIDC_USER_ROLES unset every holder of a token for the audience is at least a user, and any client of the realm that can request the audience can obtain one, which OIDC_BEARER_CLIENT_IDS narrows to the listed clients. A key set or discovery document that cannot be read is a fault, answered 503 and logged, not a refusal, so it writes no permission_denied line and a client keeps a token that may be good; inside a route, which answers no 503, the same fault refuses the session and is logged. The public routes that read a session, GET /api/auth/me, POST /api/auth/logout and POST /api/db/health, take the same answers from the proxy, so a client never reads an outage there as a refused token; the routes that sign in never read the header. A token that verifies for an account with no role or no usable username answers 403 with BEARER_NO_ROLE or BEARER_NO_IDENTITY rather than 401, because a fresh token for the same account is refused the same way until an administrator changes the account. An editable seed reaches the browser with its credentials so it can be edited there, but never a bearer session: a native client edits no connection, and every secret-classified field is stripped from every connection it lists. The username of an access token, as of an OIDC sign-in, is the email unless the provider marks it unverified, else a preferred_username that is not email-shaped (oidcUsername in src/lib/oidc-identity.ts), because without governance the username keys a person's saved work and a value a user could choose would open another person's. An access token cannot be revoked by Studio: it ends at its exp, or earlier under governance through revoked_before, so its lifetime is the identity provider's access token lifespan. 3.1. Applies to STORAGE_PROVIDER=sqlite and postgres only. Seven fields are encrypted; host, port, user, agentUser, database, name 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.1, passkey rows. A passkey row holds a public key, a credential ID, a user handle, a name, dates and flags, and none of them is sealed. None of them signs anything, so a dump or backup of the store yields nothing to sign in with; and write access to the store already means owning every account, since it holds the password hashes, so sealing would add no integrity the store's own access control does not give.

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.

The gap in the other direction is src/proxy.ts, and it is why stdout is the authoritative channel for boundary denials specifically. Next compiles the proxy as its own entry, which can run outside the application's main runtime, so the module-level ring buffer it pushes to is never the one GET /api/admin/audit reads. Measured in #851: an origin_mismatch or a proxy-level insufficient_role line, when the anon rate limit under 1.2 lets it through, reaches stdout and never the Admin Audit tab. The tab discloses that rather than presenting its buffer as the whole log.

A maintenance row names the engine principal the connection acts as, when the provider implements engineUser(): engineUser on the event and engine_user on the libredb.audit.v1 stdout line, on the completed, failed (success: false) and thrown rows of POST /api/db/maintenance alike. It is a user name and never any part of a secret, and a row whose provider names no engine principal carries no such key. Milvus's provider implements it: the Milvus user name, the user name before the colon when Password or token carries a user:password token with User empty, or the word token for a token with no colon.

3.4. WRITES are refused by the database itself, and each engine's boundary is its own. 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); a separate SQLite read-only open with PRAGMA query_only re-asserted before every statement; a DuckDB READ_ONLY engine handle paired with an SQL-level guard, because that flag alone still admits COPY … TO, EXPORT DATABASE and the local-file table functions; and, on SQL Server, four layers rather than one, because the engine offers no read-only transaction and no session-level read-only switch at all: a session principal verified at open to be unable to write or to reach the server's dangerous surfaces, an admission step that asks the optimizer to compile each statement without running it, a server-side row bound (SET ROWCOUNT) that stops an unbounded read before its rows are materialised, and a pinned transaction that is always rolled back. Reading the SQL is defense in depth only, never the boundary. MongoDB has no read-only session or transaction, so there the boundary is the connection's user, proven when a connection that sends statements is opened: buildInfo must be MongoDB's, connectionStatus must list only the read role's actions on collections of the bound database, and the server must refuse, with an authorization error, a delete that changes nothing on any outcome. In front of the engine the command reader refuses writes, write stages, server-side JavaScript and diagnostic stages, every command carries maxTimeMS and Studio kills the session of a read the server does not end, and a command naming any database but the bound one is refused before it is sent (src/lib/db/providers/document/mongodb/, docs/providers/mongodb.md section 12). The proof leaves a record by design: every verified open is one authorization failure in the server's log, the refused delete, which MongoDB Enterprise and Atlas also record as an authCheck failure where auditing is on, by their own documentation (not measured here). A monitor that alerts on those failures therefore sees each verified open, not each run: the open is verified when a server process opens a client for a statement-sending run, and later runs on that connection reuse the client from the profiled cache in src/lib/db/factory.ts until the cache closes it, as its idle sweep does after 30 minutes unused. Every agent client names itself libredb-studio-agent to the server and a statement-sending run's commands carry the same comment, so those records and the profiler's can be told from anyone else's ("What a run leaves on the server" in docs/providers/mongodb.md). 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 any of those providers — 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.

3.6. This is the first row that covers a database WRITE, and the claim it makes is the narrow one. Each apply emits a decision event before the provider is called and an outcome event after it, both under one correlation id, both naming the object address (kind, path, part), the resolved strategy and the outcome; and neither carries the statement, the command payload, the reader's text, the pre-image, the engine's message, the engine's code, the revision token or the plan token. The decision event is emitted outside any try/catch, so an apply that cannot be audited does not run; the outcome event is wrapped, because the engine has already acted and a broken log sink must not turn a completed apply into a 500 that invites a retry that would be a second write. Authorisation is a server-issued plan: the preview and the apply are bound by a sealed plan whose bytes the server minted, so what the user approved is what the engine receives, and a plan whose seal does not verify is refused with one audited event and no provider call.

The seal binds the SERVER as well as the bytes, and the field list is what makes that true. connectionFingerprint is the part of the seal that answers "is this the machine the plan was built against", so it is named in the row above rather than left as an implementation detail of the token: a change to the frame is a change to this control. Ten fields are hashed and each one, changed alone, sends the same approved statement somewhere else: type, host, port, database, user, the connectionString that overrides all of those when a record carries one, Trino's session schema, Oracle's serviceName, a MSSQL instanceName, and the SSH tunnel's ROUTE - its four addressing fields, because the same db:5432 reached through two different bastions is two different databases. Three more are hashed only when a record sets them, each tagged with its own name, so a record without them keeps its digest: Kubernetes' apiPath and tlsServerName, which pick one cluster among the many one host and port can serve, and amqpPort, the listener a read goes to, which can belong to another server than the one at host and port. host and port are the FAR END on both sides of the compare. The provider factory rewrites a tunnelled record to the tunnel's loopback endpoint before the driver opens, so it carries the far end alongside that rewrite and the seal hashes the far end, never the ephemeral local port, which is a property of this process and not of the server. The far end is read back off the tunnel and is never the address the factory asked for, and the tunnel pool keys a forward by connection id, by that far end and by the same four bastion fields this seal frames, so the only forward a provider can be handed is one opened to the address it seals THROUGH the bastion its record names. Measured against a live bastion in both directions on 2026-09-15: with the far end alone in the key, a record edited to name a bastion that does not resolve was served the forward already open through the real one, reached the old machine, and sealed a digest the route recomputed to the same value; with the route in the key the same record opens its own forward and fails to connect, which is what the same record on a connection id nothing is pooled under has always done. The connection's id and name are deliberately OUT, because a caller supplies them, and so is the password, because rotating a credential must not invalidate a plan built five minutes earlier. Both of the last two additions came from external review of the change that introduced the control, each with a colliding pair measured against the real module, so this list is a measured floor rather than a design intention.

What the round trip carried, on PostgreSQL: guarded on both sides, and neither guard is a parser. The day-one PostgreSQL unit is a multi-statement simple query, so every statement the reader's text carried used to run, and the audit event named only the addressed object. This repository cannot count the statements in a routine body itself: a dollar-quoted body may contain any number of semicolons, a BEGIN ATOMIC body contains them by construction, and no parser here can tell a statement separator from a character of the definition. So the engine is asked instead. The build parses the reader's submitted text alone as a NAMED prepared statement inside a transaction block it has already poisoned with SELECT 1/0, then rolls that block back: for every text that reaches this check the server performs no parse analysis, no planning and no execution, and its multi-command check still runs because it precedes the aborted-block check in the server's own exec_parse_message. A text PostgreSQL parses as more than one statement is refused, no plan is minted and nothing is sent; a server that does not answer that check is refused unsupported rather than trusted.

The aborted block is not a universal brake, and that is why the sentence above is bounded by the population it was measured over. Measured on 18.4 on 2026-09-15, one BEGIN plus SELECT 1/0 plus a named Parse per row: exec_parse_message exempts a transaction-exit statement, so COMMIT, ROLLBACK, END and ABORT answer no error at all, run, and end the very block this check opened; and an empty, whitespace-only or comment-only text answers 25P02 from Bind rather than from Parse, so the named statement is created and survives the rollback on a client that then goes back to the pool. Neither class can reach this check, and the reason is the ORDER of the build's refusals rather than the block: the identity comparison answers two refusals earlier and renders a header that is not the addressed routine's for either one. So the order is load-bearing and not only a saved round trip, and the provider's own suite asserts that neither class reaches the wire rather than leaving it to a comment.

On the apply side the round trip is counted rather than trusted. A simple query answers one result per statement, so the provider counts the results it already receives and reports interrupted with committed: "unknown", never a plain applied, when the count is not the four statements the unit is made of. Measured on PostgreSQL 18.4 on 2026-09-15, driven through the provider: the rider that used to drop another routine at HTTP 200 is now refused at build with the victim's count(*) unmoved, while a routine whose body carries semicolons inside $function$ and a BEGIN ATOMIC body of two SELECTs both still build and apply. docs/providers/postgres.md carries the full table.

What that still does NOT claim. The check is exactly as good as the server's own parser, and this type id also serves CockroachDB and Materialize, neither of which was probed. The result count says how many statements ran and never which, so it detects a round trip that did not match the plan and cannot name what it carried. And neither half widens the plan's consequence model: one statement whose effects reach beyond the addressed routine is still described by the empty consequence list the strategy carries. So the event says an edit was applied at this address, with this strategy, and with this outcome, and a reader of the log should read it as exactly that.

3.7. The MCP endpoint does not use the agent pipeline of row 3.5, so its audit trail is a control of its own. Each tool call writes a decision event before any provider is reached and, when the decision allows the call, an outcome event after it, both under one correlation id. A decision that cannot be written stops the call before acquisition, and an outcome that cannot be written withholds the result; either way the client reads one fixed sentence, and neither the sink's message nor a driver's. Arguments that fail a tool's input schema are refused by the SDK before any handler runs, so the route records that refusal after the SDK has answered. Only closed vocabularies are recorded, with one stated departure: the user is the MCP token's verified username rather than a mode and role label, because who ran a query is what a per-user machine credential exists to answer. A connection is recorded by its bare seed id and only once it resolved; the connection id a client sent is never recorded.

3.8. Partial for one precondition: the mode is a boundary only on a managed seed, which resolveConnection re-resolves from the operator's file whatever a caller sends, and only where etcd authenticates the client with a secret that seed keeps on the server, a password or a client certificate. On an etcd that authenticates nobody, and on a connection of the user's own, it is a safety rail, because a user can reach the same host and port with a connection of their own and can turn off Read-only on one (Known limits). readOnly is checked by one function, assertReadOnlyHonoured, before any provider is built and before any SSH tunnel is dialled, so on an engine whose provider does not keep the mode readOnly: true is refused rather than ignored, and the seed loader refuses it there too, and on a seed that is not managed. etcd's, Neo4j's, Milvus's and Qdrant's providers keep the mode today, and so do both InfluxDB providers, Oxia's, S3's, Kubernetes's, MQTT's and OPC UA's (READ_ONLY_ENFORCED in src/lib/db/compatibility.ts): etcd's names where the mode was set in every refusal (docs/providers/etcd.md section 3.4), and Neo4j's refuses every write whatever the mode says (row 3.10). Qdrant's runs reads only, so the mode changes nothing a request can do today, and on a Qdrant server without a key it binds only the seeded connection (docs/providers/qdrant.md section 3.4). Milvus's names where the mode was set, as etcd's does, in refusing Load and Release, and on Milvus the mode binds only when the server has authorization enabled, which is not its default (docs/providers/milvus.md section 3.4). The InfluxDB providers keep it whatever the flag says: each runs only statements its read policy allows (rows 3.12 and 3.13) and has no object edit and no maintenance operation, so a read-only connection refuses every non-read before any request. Oxia's names the read-only mode while it holds and says v1 reads only otherwise (docs/providers/oxia.md section 3.5). S3's runs reads only, so the mode changes nothing a request can do (docs/providers/s3.md section 3.5).

3.9. The protected set is the storage roots of the Kubernetes distributions measured for #1089, each with and without its leading /: /registry/, registry/, /kubernetes.io/, kubernetes.io/, /openshift.io/, openshift.io/, /bootstrap/, bootstrap/, /k3s/ and k3s/; RKE2's root, /rke2/ and rke2/, inferred and not measured; and the one exact key compact_rev_key, which sits outside every prefix and whose deletion makes each running kube-apiserver panic on its next compaction. RKE2's root comes from its source rather than from a store: RKE2 builds the k3s code with the program name rke2, and k3s names its own root after that program name. Every such write is refused before any request but a lease revoke, which first reads the keys its lease holds with one LeaseTimeToLive and is refused when one of them is protected or when etcd will not show them, because revoking a lease deletes its keys. Under a prefix this set does not name, only a single-key write over a stored protobuf envelope or encrypted value is recognised, and docs/providers/etcd.md lists what is not.

3.10. The read policy is the boundary and reads tokens, so a keyword inside a string, a comment or a backtick name is never read as one; the statement gate and the READ session are defence in depth. Neo4j classifies LOAD CSV and TERMINATE TRANSACTIONS as reads and READ mode does not stop a LOAD CSV, which was measured fetching a private address from the server, so LOAD, TERMINATE and the apoc. and gds. namespaces are refused by the policy before anything is sent. Every list is NEO4J_POLICY_PROFILE, and docs/providers/neo4j.md section 3.2 states each entry, pinned by tests/unit/db/neo4j/provider-doc.test.ts.

3.11. Studio refuses every Qdrant inference object (a text, image or object input with a model) except the local BM25 model, in every release. A request with such an input makes the Qdrant server call an inference service that the operator configured, passing the input, any request header ending in -api-key and the caller's identity; some hosted models are billed; and a statement could carry a third-party API key into query history. Studio never sends these inputs, so the console cannot trigger that path.

3.12. On InfluxDB 1.x and 2.x this policy is the only boundary between a Studio user and DROP DATABASE, so it rests on reading text exactly as the server does. The lexer normalises \r\n and a lone \r to \n before anything reads the text, ends a -- comment at \n, does not nest /* */, and takes exactly the escapes the server takes in strings, quoted names and regexes. A / is division when the token before it ends an operand (a name, a number, a duration, a string, a bound parameter, a regex, ), *, TRUE or FALSE, or any word right after ::) and starts a regex otherwise; this operand rule replaced a list of regex places, which missed SHOW MEASUREMENTS ... WITH MEASUREMENT = /re/, where the parser reads a regex source. Every extra regex position the rule adds is one where a leading / has no left operand and the server fails the whole request. The scanner it follows is pinned: 1.13.1 pins influxql v1.4.1, 2.9.1 pins v1.3.0, whose scanner is identical, and 3.12.0 parses /query with a separate Rust parser. A differential corpus, captured on all three lines with a READ principal where the line has one, holds that every text the server would run as more than one statement, or as a first statement that is not a read, is refused. Keywords are folded in ASCII only, so a Turkish default locale cannot turn into into a word the policy misses. The policy reads the text, never the server version, so its verdict is the same on every generation; and an answer holding a result for any statement but the first is an error naming a lexer disagreement, never rows. On InfluxDB 3 and an unknown version, _internal holds the server's token table, so a statement naming it at any depth is refused, as is a connection whose database is _internal.

3.13. On InfluxDB 3 the engine is the boundary: its SQL endpoint refused every one of the 34 SQL write forms measured on 3.12.0. The policy is defence in depth beside the closed route table, which reaches /ping, /health, /api/v3/query_sql and the database listing, a GET on /api/v3/configure/database, and no other write, configure, token, cache, plugin or engine route. The policy reads the first keyword under the DataFusion grammar row measured on 3.12.0, so a comment or an opening parenthesis cannot hide a statement that is not a read, and it refuses a second statement; every keyword it allows is pinned by a 3.12.0 capture.

3.14. Oxia's server decides where reads go: each shard's leader is an address its shard map names, and Studio sends the token on every call, so without a policy a server could send that token to any host it names. The policy admits only the address Studio already chose to send the token to, or one the operator listed under Data servers, and refuses everything else before a call is made on it.

3.15. The Oxia client's stub is built from a service definition filtered to five methods, so it has no write method at all and no code path in Studio can send a write, whatever the parser or the mode decide. Keys under __oxia/ are Oxia's own internal records, and they are refused at the parser and again at the client, so no surface reaches them by another path.

3.16. A DuckDB connection carries a server-side path, and DuckDB installs and loads a "known" extension as soon as a file or a statement needs one: measured on 1.5.5, a Test Connection on a SQLite file downloaded the 34 MB sqlite_scanner extension from extensions.duckdb.org, loaded it into the server process and checkpointed the SQLite file's WAL into it. Every DuckDB handle now opens with autoinstall_known_extensions, autoload_known_extensions and allow_community_extensions off, and the file's header is read first, because an extension already installed on the host is still loaded to attach a file it recognises with both autoload options off. Community and unsigned extensions cannot be re-enabled inside a session, so only DuckDB's own signed extensions can load on any handle. The control covers opening a connection, not what an admin editor can do once it is open, and an admin editor still reaches the network by design. Measured on 1.5.5: SET custom_extension_repository = 'http://<host>/...' followed by INSTALL <name> makes the server request that host, and after INSTALL httpfs; LOAD httpfs, read_csv('http://<host>/...') makes it request any URL a user names, internal addresses included (server-side request forgery). Both were open before this control, with a lower bar. This is why docs/providers/duckdb.md §14.3 calls an admin DuckDB connection closer to a shell on the Studio host than a database login: grant admin accordingly. The agent read-only profile has none of this reach (row 3.4), and neither does an editor handle under the denied posture of control 3.17 below: it opens with enable_external_access: 'false', so the file reads, the COPY writes and the network routes above all answer the engine's Permission Error, while the connection's own database stays writable. The full account is in §3.14, §3.16 and §14.4 of that page.

3.17. The vector 3.16 leaves open for an admin, reading any file the Studio process can with read_text and the other file-table functions, is closed for every non-admin role. The editor handle's file and network reach is enable_external_access, which is independent of access_mode: measured on 1.5.5-r.5, a writable handle with it off refuses every statement that reaches the network or a file outside its database and its temp directory, while CREATE, INSERT, UPDATE, DELETE and ATTACH ':memory:' still run. Each such handle opens with a private temp directory under the operating system's temp directory and removes it when it closes, so what a statement on it still reaches is that directory and the database's own file names, the database file and its write-ahead-log siblings, which for :memory: are four fixed names in the working directory (section 3.16 of docs/providers/duckdb.md). That boundary is statement-level only: the database the connection names is still the caller's choice, so a denied handle still opens, reads and writes any DuckDB-format file the Studio process can access, and only a file that is not a DuckDB database is refused before the engine opens it. editorExecutionContext derives the posture from guard.session.role and the resolved connection, and it travels on ProviderExecutionContext.allowExternalFileAccess, which DuckDB and SQLite read (SQLite refuses a denied handle at connect, see Embedded file connection boundaries) and which is never taken from the request body; absent means deny. An admin keeps full reach on an inline connection and on a seed only admins can use, and every other role is denied. On a DuckDB seed a non-admin role can use, admins get no file access on that connection either, because DuckDB allows one writer per file in a process: every role resolves that seed to one record, two postures would be two cached read-write handles on one file, and a second read-write handle loses the other's committed rows on Linux and macOS and is refused on Windows. getOrCreateProvider keys the handle cache by the posture, the single-writer borrow lends a handle only to a caller of the same posture, and Test Connection beside a writer of the other posture opens its test handle read-only. This is what makes the CapRover discovery export (Known limits) unreadable by the standard login through DuckDB.

3.18. The S3 client is built over a method set of GET and HEAD alone, so no code path in Studio can send a write to an S3 server, whatever the parser or the mode decide. The console's commands are a closed table, and a command or flag outside it is refused before any request with the sentence its row names. A key's . and .. segments are resolved with empty segments skipped, the reading with the fewest levels, so a key that stays inside its bucket on a server that keeps empty segments cannot leave it through a proxy that merges slashes; objectPath applies the same check to every caller, so a caller that skips the Source tab's check still cannot build such a path.

3.19. An S3 provider that fell back to the server's own credentials would hand every Studio user the server's cloud identity, so Studio has no such fallback: an empty key pair is an unsigned request, never an ambient one. With the HTTP guard off, an endpoint of http://169.254.169.254 would put instance metadata one GET away, which is why the byte transport S3 uses refuses the link-local networks and AWS's IPv6 metadata address whether or not the guard is on (row 0.6), before the S3-proving connect probe ever runs, while loopback and private addresses stay usable for local servers. Elsewhere, the connect probe is what keeps a host that does not speak S3 from having a body read: a connection reads no object until its probe parsed as S3 XML. A metadata service a cloud serves outside those networks is not refused; none is claimed. A Parquet footer and its page headers are data the object supplies, so the preview checks them before its decoder reads them: a varint longer than its type allows or a long-form field id outside 1 to 32,767 is refused, so the check and the decoder read the same values, and a size, count or offset that is negative or above 2^53 - 1 is refused, so no value can lower a sum below its cap. A rendered cell is cut while its string is escaped, never escaped whole and cut afterwards, so a decoded string far past the cell bound costs no more memory than the cell.

3.20. A Kubernetes identity often holds more than reading: get on pods/exec opened an exec session before 1.35, and get on nodes/proxy reaches every node's kubelet, so read-only cannot rest on the identity. It rests on what Studio sends: the allowlist is checked on the final strings just before the transport, after the API path is joined, names are refused before a URL is built, and no query key comes from user text, so no statement, tree action or prefix can reach a subresource the allowlist does not name. Secret redaction is keyed on the request, because a typed list's items carry no kind and a metadata row says PartialObjectMetadata, and it runs once, after the body is parsed and before any grid, document, YAML view or sentence is built. Errors are sentences Studio composes from the request and the answer's code and reason; the server's own message, which names the identity and can echo a value, reaches no surface.

3.23. Under governance a control plane stores every connection credential as an envelope sealed to Studio's public key, so a reader of its database or of a backup holds the credentials as ciphertext only, and a writer of it cannot point a stored credential at a host, port, TLS mode, engine, level or connection of its choosing: the binding is checked by the cipher itself, on every open, including one the cache answers. The keyring is the residual: a Studio process holds every governed credential, and a leaked private key together with any copy of the governance source's database opens every envelope sealed to it, which is why the keyring is a file and never an environment variable, and why a leaked key means rotating every governed database credential (docs/GOVERNANCE.md).

3.24. A query event row is read by the control plane's administrators and kept as long as the control plane retains query events, so it must not become a second copy of the data and the secrets the log accounts for. buildQueryEvent() builds every row from the fields QueryEventInput names and nothing else, so a caller that hands it a request body, a result or a resolved connection still produces a row without them. redacted reads a statement the way its engine reads it, so a literal ends where the engine ends it, and the statement of an engine Studio has no reader for is kept as a single ?. It stores no error text and replaces every literal, so a secret written as a string or a number is gone, while a secret written as a bare name is kept like any other name, the password of IDENTIFIED BY excepted. full is the wider residual: it keeps every other secret written into a statement, such as a Redis AUTH argument or a token passed to a function, and in the error text it masks a password only where the words around it read as they would in a statement, so a driver that quotes a statement its own way can echo one (docs/GOVERNANCE.md).

Known limits

These are real, current, and not oversights. Each is a decision with a reason.

  • A server-level PostgreSQL connection exposes every database its role can CONNECT to (#1530), to every user it is offered to. The boundary is the role's own CONNECT privilege, checked by the server; a seed meant for one database names it and is then pinned to it.
  • The HTTP destination guard is opt-in and address-based. DB_HTTP_BLOCK_PRIVATE_HOSTS is off by default so local databases remain usable. When enabled, it covers ClickHouse, Druid, Elasticsearch, OpenSearch, Trino, libSQL, Couchbase, Prometheus, Qdrant, InfluxDB (InfluxQL), InfluxDB 3 (SQL), S3-compatible object storage and Kubernetes HTTP requests, rejecting loopback, private, link-local, unique-local and selected special-use IP ranges at the literal-host and DNS/socket boundaries. A public service address such as Azure's 168.63.129.16 remains reachable because the policy checks address ranges, not ownership or service purpose. Non-HTTP drivers, including Kafka, and the SSH tunnel host are outside this guard. HTTP connections routed through an SSH tunnel are refused while the flag is enabled because their request target is a local tunnel endpoint. The byte transport refuses link-local addresses and AWS's IPv6 instance metadata address whether or not the flag is on; under an SSH tunnel it checks only the local forward it dials, so the far end's address literal is left for its provider to check, and a far end given as a name is resolved by the SSH server, where Studio cannot check it. A byte-transport path may carry dot segments that the server, or a proxy in front of it, resolves to another path on the same origin; the origin itself never changes. Oxia is reached over gRPC, as Milvus is, and is outside this guard: grpc-js resolves names itself, so an address check would be believed and not hold; a pinned gRPC guard is a backlog entry (D212). Apache Spark is reached over gRPC through the same shared transport, and is outside this guard for the same reason: grpc-js resolves names itself, so an address check would be believed and not hold; the same backlog entry (D212) covers it.
  • A PostgreSQL-wire cancel opens one more connection to the server. Where pg_cancel_backend is refused (CockroachDB, Materialize, RisingWave), Cancel sends the protocol's CancelRequest, carrying the session's process id and secret key, on a fresh connection to the session's address. It is TLS-encrypted whenever the session is, with the session's own TLS options and server name, and is not sent at all if such a server refuses TLS or presents a certificate the session would not trust; without TLS it is plaintext, as the session is (docs/providers/postgres.md section 5.3, #1364).
  • 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. In server storage mode the browser copy belongs to the signed-in account: it is cleared on sign-out, and a different account signing in starts from its own server data. Every request a tab sends after matching its copy names that account, and outside the public routes (/api/auth/*, /api/db/health and the rest of the public set in src/proxy.ts) the server refuses one that names an account other than the signed-in one, so a tab still open for one account never writes its workspace into another account's server storage. A sign-out that could not complete, or a session that ended without one (expiry, a disabled account), leaves the copy in that browser until the next sign-in's owner check, which clears it for a different account. In local mode it stays, and the next account to sign in on that browser profile sees and can open the connections saved there.
  • 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. ALLOW_CUSTOM_CONNECTIONS=false (row 0.7) is the one coarse control there is: it narrows the connections a user can open to the ones the operator seeded, though a seeded Kafka connection still reaches whatever hosts its brokers advertise (below), and nothing narrows the statements, so a seeded connection still runs whatever its credential allows. A Kafka broker's metadata chooses the further hosts Studio connects to, with the connection's SASL credentials: a Kafka client reaches every broker at the address the broker advertises, so a hostile or misconfigured broker can direct Studio, with those credentials, to any host and port. Use TLS with verify-full so credentials reach only hosts whose certificate the configured CA vouches for. A Kafka connection refuses an SSH tunnel for the same reason: the tunnel would carry the bootstrap connection only, and every read would go to the advertised addresses directly. And the Kafka client decompresses every batch of a fetch response whole, before any bound Studio applies, while the broker bounds only the compressed batch, so one response can hold its compressed size times the codec's ratio, measured at about 1,029 to 1 for gzip and 32,692 to 1 for zstd, in the one process every user shares; a hostile broker is bounded by neither, because the client buffers whatever frame length the broker announces (docs/providers/kafka.md section 5.4). The Kafka client's SCRAM exchange runs PBKDF2 over the password as many times as the broker's first SCRAM answer asks, checking only the lower bound, while Apache Kafka 4.3.1 stores no credential above 16,384 iterations, so a hostile broker, or one in the middle of a connection in TLS mode require, can hold threads of the process's pool for minutes per connect, which stalls every file read, DNS lookup and crypto call waiting for a thread (docs/providers/kafka.md section 4.2). A broker that answers a SASL authentication with a session lifetime (KIP-368) has the Kafka client authenticate again at 80% of it, for as long as the connection is open and with no floor, so a hostile or misconfigured broker that answers 1 ms keeps about 60% of a core busy per SCRAM connection of a cached provider, up to 35 minutes after its last use, when the factory's idle sweep evicts it (docs/providers/kafka.md section 4.2). An etcd connection's read-only mode (row 3.8) does not narrow this: it binds a user only on a managed seed and only where etcd authenticates the client with a secret only the seeds hold, a password or a client certificate, so on an etcd that authenticates nobody a user who knows the address writes through a connection of their own (docs/providers/etcd.md section 3.4). An etcd write cannot be cancelled once it has been sent: the provider's cancel answers false, and the editor says the cancel was not confirmed rather than that it happened (#1364), so a user reads the key again before running the command again. The etcd provider's Kubernetes write protection is by prefix, and by content only where a single-key write meets a stored Kubernetes envelope: under a custom --etcd-prefix, a range write, a value written between a txn's read of its single-key targets and its send, a lease revoke, a new key, and a key holding Kubernetes JSON or CBOR are not recognised. A Neo4j connection's read policy (row 3.10) does not narrow this either: it decides which statements run on that connection, and a user can still post a connection of their own to any host and port.
  • A statement the editor refuses is never sent and never written to history. A connection type can declare statements its editor refuses, and a bound on a statement's size in UTF-8 bytes, on its row in src/lib/db/destructive-commands.ts. Such a statement is refused in the browser before the confirmation dialog, on every path the editor takes: Run, Proceed, Explain, Load More and playground mode, and the embedded workspace's four run paths. It reaches no route and no host callback, and it is not written to query history. POST /api/db/query answers 413 for a text over the bound and POST /api/db/multi-query refuses such a type with 400, so a caller outside the editor meets the same bound. The Milvus, Qdrant, InfluxDB (InfluxQL), Oxia, S3 and Kubernetes rows declare both (rows 3.11, 3.12, 3.15, 3.18 and 3.20); no other shipped engine declares either. A statement the editor accepts is written to history whole, as on every engine, even when the server then refuses it, except for the values of the credential flags its dialect declares as historyMask on its QUERY_DIALECTS record, which are replaced by *** before the entry is stored, in comments too, and in the error text the entry stores, since an error can repeat the statement (src/lib/history-mask.ts); the kubectl dialect declares --token, --password, --client-key, --client-certificate, --from-literal and --docker-password, though its console refuses every line that carries them. It does not mask a value written before the flag that names it, as an error can quote one, a value on a later line than its flag that no continuation or open quote carries there, or the value of a flag spelled in a way the module does not read, such as with a dash for a hyphen inside its name. That mask reaches the standalone app's query history only: a tab keeps what the user typed, a saved query keeps what the user saved, and the embedded workspace writes no history of its own; a statement run before the connection's metadata has loaded is stored under every dialect's declared flags at once and masked as a command line is, so a SQL statement holding a declared flag and a value may then be stored without that value, without the quotes around it or cut to the end of its line, a statement that may no longer parse, never a credential.
  • Milvus is reached over a gRPC client of Studio's own. A Milvus password or token over no TLS is refused unless the host is a loopback address or localhost, or an SSH tunnel carries the connection. TLS mode require sends the password to an unverified peer. gRPC, and every request the Milvus server itself makes, is outside the HTTP egress guard. Vectors and payloads reach query history and saved queries, as every statement's text does. A filter's text is echoed in errors and in the Studio log. A load holds shared query-node memory until it is released, for every client of the cluster. Milvus RBAC hides neither the topology nor other databases' row counts from any authenticated user, since GetMetrics needs no privilege. A dbName in a body overrides the connection's database, so only Milvus RBAC on a user that is not root scopes a seed to one database. A seed's in-flight slots, queue and running requests are shared by every user of the seed, so any user who knows a run's id can cancel it, and the bound counts Studio's calls, not the server's work. readOnly binds only on a managed seed whose secret only it holds, and only when the server has authorization enabled: Milvus with authorizationEnabled: false, its default, accepts any credential or none, so a connection that works proves nothing about the server and Studio cannot tell. REST entities/get and entities/delete by a VarChar id holding a quote differ from Studio's template on 3.0.2. A vendor address pasted into the connection-string box instead of the Host box still selects ClickHouse. Zilliz users should prefer a cluster-scoped user or key to a project-wide key. A hostile or impersonated endpoint holds the password it receives and can return it in an encoding Studio does not list, so Studio withholds only the forms it sends. The management port 9091 answers without authentication on 3.0.2, so the operator keeps it off the network. docs/providers/milvus.md section 13 lists the same limits, and its section 12 how to run Milvus for Studio.
  • Oxia has no authorization. A token that authenticates reads and writes every namespace, so Studio's read-only mode is a property of what Studio sends (it has no write call at all, row 3.15), never of the token. A standalone server has no authentication. An advertised leader decides where reads go, which row 3.14 bounds, and a Data servers entry is the operator's statement that the token may go there.
  • Qdrant is reached over a REST client of Studio's own. A Qdrant API key or JWT over no TLS is refused unless the host is a loopback address or localhost, or an SSH tunnel carries the connection. TLS mode require sends the API key to an unverified peer. The server's own egress, its inference calls and its snapshot recovery included, is outside the HTTP egress guard. Vectors and payloads reach query history and saved queries, as every statement's text does. Any read credential can download whole snapshots and read the server's telemetry outside Studio. A collection-scoped token with write access can delete a named vector from every point of its collection outside Studio. The Qdrant server logs every request's path and query string, so collection names appear in its log. A stopped query runs on the server until its server timeout, and an exact count runs past it until it completes. A seed's in-flight slots, queue and running requests are shared by every user of the seed, so any user who knows a run's id can cancel it. Studio cannot tell what an opaque key can do: readOnly is enforced by Studio whatever the key, and a server-side boundary exists only when the key itself is read-only on the server. A Qdrant server without service.api_key, its default, accepts any key or none, so it has no boundary at all. Prefer a read-only or collection-scoped key, or a JWT with an expiry. An empty tree is what a scoped token, an alias-only token and an empty server all produce. A vendor address pasted into the connection-string box instead of the Host box still selects ClickHouse. A hostile or impersonated endpoint holds the key it receives and can return it in an encoding Studio does not list, so Studio withholds only the forms it sends. docs/providers/qdrant.md section 13 lists the same limits.
  • S3-compatible object storage is reached over a REST client of Studio's own. A request over no TLS, signed or not, is refused unless the host is a loopback address or localhost, an SSH tunnel carries the connection, or the connection consents. Anyone who captures a signed request can send it again while the server's clock window lasts: 14 minutes of skew were accepted and 20 refused on MinIO, Silo and RustFS, while Garage accepted a signature up to 24 hours old and any date in the future. TLS mode require sends signed requests to an unverified peer. Studio cannot tell what a key can do: read-only is a property of what Studio sends, and a server-side boundary exists only when the key itself is read-only on the server. An empty tree is what a scoped key, a key that may list no bucket and an empty server all produce. With DB_HTTP_BLOCK_PRIVATE_HOSTS off, the default, an S3 connection reaches any loopback or private host that answers as S3, and reads the objects that host serves to the connection's key; turning that flag on (row 0.6) or setting ALLOW_CUSTOM_CONNECTIONS=false (row 0.7) narrows it, and a policy that differs by role is a backlog entry (D270). The Bucket field narrows what Studio lists and opens; it is a scope, not an access boundary, and the key's own policy on the server is the boundary. A key whose . and .. segments, resolved with empty segments skipped, climb out of its bucket or name the bucket itself is refused wherever an object path is built, on every connection and before any request, so the scope holds under a server or proxy that resolves dot segments or merges slashes; such a key is listed but not opened. Object keys and folder names typed into a console command the editor accepts reach query history, as every accepted statement's text does; a command the editor refuses never does. docs/providers/s3.md section 13 lists the same limits.
  • InfluxDB read-only is a property of what Studio sends. On InfluxDB 3 Core every token is an admin token, which reaches plugin upload and execution outside Studio, so read-only there is a property of what Studio sends and nothing on the server keeps it. On 1.x and 2.x the server keeps a read-only mode only with a READ user or a read bucket token; prefer one for every Studio connection. TLS mode require sends the password or token to an unverified peer. The consent to send a credential over plaintext to a host that is not loopback is per connection and visible on it.
  • Env-mode local passwords are not hashed. With STORAGE_PROVIDER=local they arrive as ADMIN_PASSWORD and USER_PASSWORD, so the environment already holds the secret. On sqlite or postgres those variables only seed the account registry, and the stored value is scrypt (N=16384, r=8, p=1); see STORAGE.md. Rate limiting (1.2) and the constant-time comparison (1.5) still apply to both paths.
  • Rate limiting is per process and every bucket is keyed on something the caller supplies. See docs/BACKLOG.md, entries H11 and H13. Two variables decide what that something is. TRUST_PROXY_HEADERS (default true) derives the client address from X-Forwarded-For, falling back to X-Real-IP; set to false, every anonymous caller shares one bucket, so a single caller tripping the login limiter locks everyone out until the window closes. TRUSTED_PROXY_HOPS (default 0) picks which X-Forwarded-For entry is the client. 0 takes the leftmost entry, which the caller writes, so behind a reverse proxy it should be set to the number of proxies in front of Studio. Set too low, a caller can choose the bucket they land in; set too high, it keys on a proxy and lumps everyone behind it into one bucket. The same derived address is the ip field in the audit log, so a wrong value also makes that field unreliable. Both are documented in .env.example under Forwarded Headers.
  • The CapRover auto-connect variant runs a companion with the Docker socket, which is root on the host. It is an operator decision, made by choosing the separate libredb-studio-autoconnect template; the plain CapRover template, the chart and every other channel never mount the socket. The exporter, docker/discover.mjs, sends GET requests to two Docker Engine API paths only, but that is a property of its code and not of the socket: whoever controls the companion's image tag or process controls the host. It has no listening port, and the Studio web process never touches the socket; it reads one file the exporter writes, and treats that file as untrusted input. The file holds the name, host alias, image and task counts of every non-system app on the CapRover network that is not listed in "Apps to skip", only the names of the apps that are, and the values of ten allow-listed database environment keys, in plaintext, mode 0600 and owned by uid 1001, on a named volume; the same passwords are already plaintext in each service spec and in CapRover's own configuration. Every discovered connection is managed, admin-only and never offered to MCP clients, its values are used as literal text so no ${NAME} or ${vault:...} in another app's environment is resolved, and a standard user who names a discovered id gets the same 404 as for an unknown id. The standard user cannot read the export file through DuckDB: a non-admin DuckDB handle opens with statement-level file access closed (section 3.16 of docs/providers/duckdb.md, control 3.17), and the export is JSON, which DuckDB refuses to open as a database. An admin's own DuckDB connection can still read it (docs/providers/duckdb.md section 14.3), so a stolen admin session reaches the discovered databases' credentials, and the admin login should be treated as holding every one of them. The connections carry the superuser credentials CapRover's database templates create, so a stolen admin session reaches every discovered database; the template ships AUTH_COOKIE_SECURE set to false until HTTPS is on, and the admin Overview page warns while the session cookie can travel over plain HTTP. docs/SEED_CONNECTIONS.md describes the variant, and deploy/caprover/README.md how to install it.
  • 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, assistant messages included: an identifier the model quotes back into its own tool arguments is defanged on the way into the transcript, while reasoning parts pass through because signed thinking blocks cannot be rewritten. 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() (config.ts) requires LLM_API_KEY for the gemini and openai kinds only, 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 through DEFAULT_PROVIDER (config.ts), it is refused without a key, availability answers NO_MODEL_CONFIGURED, no rail renders and no model call is made.
  • A page on a sibling subdomain can time an authenticated endpoint on this one. The auth cookie is SameSite=Lax, which withholds it from a cross-site subresource request but sends it to a same-site, cross-origin one — so an attacker who controls any host under the deployment's own registrable domain can load a Studio URL as a no-cors subresource and read the load/error and coarse-timing signal, even though the response bytes stay opaque to them. Cross-Origin-Resource-Policy: same-origin is the control that closes this, and it is refused for now with the reason recorded in next.config.ts: its correct value is a property of the deployment topology, and the only header path that reaches a subresource is baked at build time, where an operator cannot change it. Closing it properly means a runtime delivery path that reaches a subresource, which the two-path split above does not have.
  • User verification is the authenticator's claim. Row 1.8 refuses an assertion without it, but some password managers set the flag without verifying anyone, and without attestation, which public-facing sign-in does not require, the server cannot tell.
  • A new passkey is announced nowhere outside Studio. There is no email or other out-of-band channel; an addition shows in the audit log, in the owner's dated passkey list and in the admin's per-account count, and an in-session notice is not implemented.
  • An account with a passkey but no TOTP binds another passkey with its password. This departs from NIST SP 800-63B-4 4.1.2.1, which would require an existing passkey; the reason and the recovery that bounds it are in the note on 1.8.
  • While passkeys are ready, injected script can request assertions for a parent domain. WebAuthn lets a document on studio.example.com ask for credentials whose RP ID is a registrable parent such as example.com, so script injected into a Studio page could exercise another application's passkeys when that application uses the parent as its RP ID and accepts subdomain origins. Where passkeys are off, the Permissions-Policy of 1.1 denies publickey-credentials-get and Chromium blocks that; while they are ready the denial is dropped, because it would also block Studio's own sign-in. Studio's own RP ID is always its full hostname, never a parent.
  • 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

Embedded file connection boundaries

SQLite, DuckDB and LibreDB connection targets cannot name reserved server state or credential files, including filesystem aliases. Other database files in the same directory remain usable. These checks apply to every role and are checked before opening the target.

SQLite connections require trusted file access. Studio denies them to non-admin callers and on managed connections shared with non-admin roles, including agent and MCP usage. Unlike DuckDB, SQLite's adapters cannot confine statement-level access to one database. The cached provider factory requires embedders to explicitly grant trusted SQLite access with { allowExternalFileAccess: true } in the server-owned execution context.