Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,21 @@ STORAGE_PROVIDER=local
# Cloud PostgreSQL with SSL:
# STORAGE_POSTGRES_URL=postgresql://user:pass@host:5432/libredb?sslmode=require

# Credential encryption key for the SERVER-SIDE store (sqlite/postgres only) — OPTIONAL.
# When STORAGE_PROVIDER is sqlite or postgres, database passwords, connection strings, TLS
# client keys and SSH keys/passphrases are encrypted before they are written, so a stolen
# database file or dump is useless on its own.
# Leave this unset and the key is derived from JWT_SECRET, so there is nothing to configure.
# Set it (at least 32 characters, generate with: openssl rand -base64 32) when you want the
# storage key separated from the session-signing key — for example so JWT_SECRET can be
# rotated without invalidating every saved connection password.
# IMPORTANT: rotating whichever key is in use makes existing stored credentials unreadable.
# They are omitted from the connection, not deleted; the rest of the connection survives and
# you re-enter the password once. Restore the previous key BEFORE the app writes again if you
# want the old values back.
# Browser localStorage is NOT encrypted; this variable does not change that.
# STORAGE_ENCRYPTION_KEY=your_32_character_random_string_here

# ===========================================
# SQLite DB Provider Driver (advanced)
# ===========================================
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ jobs:
# engine table and install commands, and nothing else notices drift.
run: bun run readme:check

- name: Security posture drift guard (control 3.3)
# Same reasoning as the two guards above: inside the required check so it actually gates.
# docs/SECURITY.md claims which controls exist and names what verifies each one; this fails
# the build when a row points at a file that is missing or never runs, when a security test
# exists that no row accounts for, or when a control claims to be implemented with nothing
# verifying it.
run: bun run security:check

- name: Check formatting (Biome)
run: bun run format

Expand Down
26 changes: 18 additions & 8 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

We actively support and provide security updates for the following versions of LibreDB Studio:

| Version | Supported |
| ------- | ------------------ |
| 0.9.x | :white_check_mark: |
| < 0.9.0 | :x: |
| Version | Supported |
| -------- | ------------------ |
| 0.10.x | :white_check_mark: |
| < 0.10.0 | :x: |

> **Note**: We recommend always using the latest version to ensure you have the most recent security patches.

Expand Down Expand Up @@ -82,10 +82,20 @@ When using LibreDB Studio, please follow these security best practices:
today

#### Database Connections
- Connection details, including database passwords and SSH private keys, are stored unencrypted
in browser `localStorage`, and in the server-side store when `STORAGE_PROVIDER` is set to
`sqlite` or `postgres`. Treat both as secret material: anyone who can read that browser
profile or that database can read every configured credential.
- Connection details, including database passwords and SSH private keys, are stored **unencrypted
in browser `localStorage`**. Treat that browser profile as secret material: anyone who can read
it can read every configured credential. This is deliberate — it is what allows Studio to work
without a master password — and it is why cross-site scripting is treated as a top-severity
issue in this project.
- In the **server-side store** (`STORAGE_PROVIDER=sqlite` or `postgres`), those same fields are
encrypted at rest with AES-256-GCM before they are written: the database password, the
connection string, the TLS client key, and the SSH password, private key and passphrase. The key
is `STORAGE_ENCRYPTION_KEY` when set, and is otherwise derived from `JWT_SECRET`, so there is no
new required configuration. Host, port, user and database name stay readable so an operator can
still identify what a dump contains. A leaked database file or backup is therefore not by itself
enough to read the credentials — but anyone who can read the server's environment still can.
Rotating the key makes stored credentials unreadable; see
[docs/STORAGE.md](docs/STORAGE.md#credential-encryption-at-rest).
- Database credentials are not written to application logs, but they are returned in plaintext
to the authenticated owner through storage API responses (for example `GET /api/storage`),
because the app must be able to redisplay a saved connection's password for editing and reuse
Expand Down
13 changes: 5 additions & 8 deletions charts/libredb-studio/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ apiVersion: v2
name: libredb-studio
description: Web-based SQL IDE for cloud-native teams supporting PostgreSQL, MySQL, SQLite, Oracle, SQL Server, MongoDB, and Redis
type: application
version: 0.1.30
appVersion: "0.9.67"
version: 0.1.31
appVersion: "0.10.0"
kubeVersion: ">=1.26.0-0"
home: https://github.com/libredb/libredb-studio
icon: https://raw.githubusercontent.com/libredb/libredb-studio/main/public/logo.svg
Expand All @@ -28,10 +28,10 @@ annotations:
artifacthub.io/category: database
artifacthub.io/license: MIT
artifacthub.io/prerelease: "false"
artifacthub.io/containsSecurityUpdates: "false"
artifacthub.io/containsSecurityUpdates: "true"
artifacthub.io/images: |
- name: libredb-studio
image: ghcr.io/libredb/libredb-studio:0.9.67
image: ghcr.io/libredb/libredb-studio:0.10.0
platforms:
- linux/amd64
- linux/arm64
Expand All @@ -43,10 +43,7 @@ annotations:
- name: Source
url: https://github.com/libredb/libredb-studio
artifacthub.io/changes: |
- "Documented how to enforce rate limits at the ingress when running more than one replica: Studio's built-in limiter keeps its counters per process, so N replicas allow N times the configured budget"
- "Documented ALLOWED_ORIGINS, which a deployment needs when the ingress rewrites the Host header without setting x-forwarded-host - without it every state-changing request, including login, is refused with a 403"
- "Documented CSP_REPORT_ONLY, the runtime escape hatch for the enforced Content-Security-Policy, and how to set both variables through extraEnv"
- "No chart template changes - the rendered manifests are identical to 0.1.29"
- "Track app release 0.10.0 (appVersion bump; default image tag follows)"
dependencies:
- name: postgresql
version: "16.x.x"
Expand Down
23 changes: 22 additions & 1 deletion charts/libredb-studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ helm install libredb libredb/libredb-studio \

```bash
helm install libredb oci://ghcr.io/libredb/charts/libredb-studio \
--version 0.1.30 \
--version 0.1.31 \
--set secrets.jwtSecret=$(openssl rand -base64 32) \
--set secrets.adminPassword=MyAdmin123
```
Expand Down Expand Up @@ -245,6 +245,27 @@ helm install libredb libredb/libredb-studio \
--set extraEnv[1].value="true"
```

### Separating the storage encryption key

With `STORAGE_PROVIDER` set to `sqlite` or `postgres`, connection credentials are encrypted at rest
using a key derived from `JWT_SECRET`. Nothing needs configuring for that to work. Set
`STORAGE_ENCRYPTION_KEY` when you want the two separated — most usefully so rotating the
session-signing secret does not invalidate every saved connection password, and, for
`STORAGE_PROVIDER=sqlite`, so a backup or volume snapshot of `/app/data` does not also carry the
key that opens the ciphertext it contains (with nothing set, the fallback key is persisted in that
same directory, alongside the database file):

```bash
helm install libredb libredb/libredb-studio \
--set extraEnv[0].name=STORAGE_ENCRYPTION_KEY \
--set extraEnv[0].valueFrom.secretKeyRef.name=libredb-studio-storage \
--set extraEnv[0].valueFrom.secretKeyRef.key=encryption-key
```

Rotating the key makes existing stored credentials unreadable — the connections survive and their
passwords are omitted. See
[docs/STORAGE.md](https://github.com/libredb/libredb-studio/blob/main/docs/STORAGE.md#credential-encryption-at-rest).

## External Secrets

Use `secrets.existingSecret` to reference a secret managed by External Secrets Operator, Sealed Secrets, or Vault:
Expand Down
55 changes: 55 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,3 +620,58 @@ behind it. Done when the bundled runtime's version and provenance appear in the
SBOM or a sibling document - a second Trivy pass over the `fetch-node.sh`
scripts' pinned version, or a hand-maintained component entry, whichever ships
without adding a new failure mode to the release chain.

---

## Security Phase 3 deferrals

Each of these was decided during Phase 3, not overlooked. Delete an entry when the work lands.

### K1. Nothing stops a new route from bypassing the authoritative audit channel

`src/lib/audit.ts` exports both `emitAuditEvent` (ring buffer **and** the `libredb.audit.v1` stdout
line) and `getServerAuditBuffer`, and `POST /api/admin/audit` legitimately uses the second on its
own — its body is client-supplied and must never gain authority over the authoritative channel. But
nothing prevents a future route from doing the same by accident: an event pushed straight to the
buffer is visible in the admin UI, invisible to every log pipeline, and no test notices. The
existing tests all pin the CONTENT of the stdout line, not the set of call sites permitted to skip
it. Done when a check enumerates `getServerAuditBuffer(...).push(` call sites across `src/` and
fails on any that is not on a short, commented allowlist - the same inversion
`tests/security/route-auth.test.ts` applied to route discovery, where a hand-curated list had
already lost eleven routes.

### K2. A legacy plaintext password shaped exactly like an envelope is treated as corruption

`src/lib/storage/encryption.ts`'s `readSecret` treats a three-segment value whose first segment
matches `/^v\d+$/` as an envelope. A password stored before this feature existed that happens to be
literally `v1:<base64url>:<base64url>`, with a 12-byte first segment and a second of at least 16
bytes, is therefore classified `undecryptable` and omitted rather than returned. The compounded
probability is negligible, the failure is recoverable (the connection survives and the user retypes
the password once), and the alternative - passing an unrecognised value through - would hand
`v1:abc:def` to a driver as a password. Accepted rather than designed away, because the fix would
be a longer, non-colliding prefix, and the stored envelope shape is a fixed contract. Done when the
envelope format is versioned forward for an unrelated reason, at which point a longer prefix costs
nothing.

### K3. `STORAGE_ENCRYPTION_KEY` is validated at first write, not at boot

`src/lib/config/auth-preflight.ts` validates `JWT_SECRET` at startup, so a short one stops the
server rather than producing a green health check and a 503 on every login.
`STORAGE_ENCRYPTION_KEY` has no equivalent: a value shorter than 32 characters throws only when the
first storage write happens, which is after login, after the migration attempt, and only in server
storage modes. The failure surfaces as a `syncError` in the UI rather than as a boot failure. Done
when the preflight also reads `STORAGE_ENCRYPTION_KEY` - noting that it must stay silent when
`STORAGE_PROVIDER` is `local`, where the variable is inert and an error would be wrong.

### K4. Rotating the key back does not recover credentials once the app has written

`src/lib/storage/connection-secrets.ts`'s `decryptConnections` omits an unreadable secret and keeps
the record, which is correct - dropping the record would be persisted as a deletion. But the
omission is only recoverable until the next write: `useStorageSync` is a write-through cache, so the
first push of the `connections` collection after a failed read overwrites the ciphertext with a
record that has no password field at all. The warning fires on READ, which is before any write, so
an operator who reads their logs promptly has a window. Making the window unnecessary would mean
reading the stored row before every write and preserving an existing envelope when the incoming
value is absent - which would also silently resurrect a password the user deliberately cleared, a
worse bug than the one it fixes. Done when a design is found that distinguishes "the client never
had this value" from "the client cleared this value" without adding a field to the stored shape.
Loading
Loading