DBHub’s Read-Only Mode Was Dead Code — and Three Advisories Landed at Once

On September 24, three security advisories for DBHub — Bytebase’s database MCP server for PostgreSQL, MySQL, MariaDB, SQL Server, Oracle, and SQLite — became publicly visible within about an hour of each other. The package is @bytebase/dbhub, roughly 3,565 GitHub stars, and around 115,795 npm downloads in the month ending September 21. It is also the server Anthropic names in the Claude Code MCP documentation as the worked example for connecting an agent to a PostgreSQL database.

The three issues are CVE-2026-61742 (critical, CVSS 4.0 base 9.3 — DNS rebinding against an unauthenticated HTTP transport), CVE-2026-61788 (high, CVSS 3.1 base 7.4 — read-only mode does not prevent writes), and CVE-2026-61789 (high — read-only bypass on the MySQL and MariaDB connectors). All three were fixed in June, in 0.22.5 and 0.22.6. The current release is 1.3.1, published September 21.

So this is not a patch scramble. It is something more interesting: a close reading of what happens when a product ships a safety feature whose enforcement path is never reached.

The guardrail that never ran

DBHub’s README lists “Guardrails: Read-only mode, row limiting, and query timeout to prevent runaway operations.” Claude Code’s own documentation tells users to “use a read-only database user in the connection string so the queries Claude runs can’t modify data.” Read-only is the control everybody reaches for when pointing a language model at a production database.

GHSA-mwwr-p57h-56pf, filed by an external reporter, shows that the connector-level version of that control was unreachable. The connectors do contain the right code — PostgresConnector.connect() appends -c default_transaction_read_only=on to the pool options, and the SQLite connector opens the file in readOnly mode — but both are gated on config.readonly. That field is assigned in exactly one place, in src/connectors/manager.ts, and only from source.readonly.

The reporter then walks three independent reasons source.readonly can never hold a value:

  • SourceConfig has no readonly field at all — readonly exists only on the per-tool ExecuteSqlToolConfig and CustomToolConfig types.
  • The TOML loader explicitly rejects it, with the error text “readonly must be configured per-tool, not per-source.”
  • The --readonly CLI flag had been removed and now hard-exits.

The condition if (source.readonly !== undefined) is therefore always false. The comment sitting above the Postgres branch reads // SDK-level readonly enforcement. It describes behaviour that no accepted configuration could produce.

What was left holding the line

With the engine-level control unreachable, read-only was enforced entirely by isReadOnlySQL() — a classifier that matches the first word of each statement against an allow-list, scans for mutating keywords inside WITH, blocks SELECT ... INTO, and special-cases EXPLAIN ANALYZE. As the advisory puts it, it “never looks at the functions a statement calls.”

That gap is the whole finding. Every one of these is classified read-only:

SELECT setval('users_id_seq', 1);                                   -- sequence write
SELECT lo_export(lo_from_bytea(0, decode('48656c6c6f0a','hex')), '/tmp/x');  -- file write
SELECT pg_read_file('/etc/passwd');                                 -- host file read
SELECT dblink_exec('dbname=app', 'UPDATE users SET admin=true');    -- write via new connection
SELECT dblink_exec('dbname=app', $$COPY (SELECT 1) TO PROGRAM 'id'$$);  -- command execution

The first needs only the UPDATE or USAGE privilege on a sequence — something ordinary read roles routinely hold. The rest escalate with the database role: lo_export and pg_read_file need superuser or the corresponding pg_*_server_files role, and the COPY ... TO PROGRAM path needs pg_execute_server_program plus the dblink extension. The advisory notes that privileged roles are “common, since DBHub is often pointed at an existing admin DSN,” and that the project’s read-only test suite covered none of these cases.

To the project’s credit, its documentation already described the classifier as “a safety net… not a security boundary.” The reporter is careful about this, and so should anyone reading the advisory be: the report is not that the classifier was imperfect. It is that the control which was meant to be the boundary silently did not exist, leaving the acknowledged safety net as the only thing there.

A parser disagreement is a privilege boundary

CVE-2026-61789 is a separate root cause and, mechanically, the most elegant of the three. DBHub’s SQL parser treats -- as the start of a comment regardless of what follows. MySQL and MariaDB only begin a -- comment when the dashes are followed by whitespace, a control character, or end-of-line; otherwise the characters are two minus signs and the rest of the line is ordinary SQL.

The payload is one line:

SELECT 1--1;DROP TABLE victim

DBHub’s splitter sees a single SELECT with a trailing comment and passes it. MySQL reads SELECT 1 - -1, then a second statement, and executes both — because the MySQL and MariaDB connectors open their pools with multipleStatements: true and then run the original, unmodified string rather than the statements that were validated. The reporter verified both halves: DBHub’s own splitSQLStatements and isReadOnlySQL return one statement and readOnly = true, and a MariaDB 11.4 instance driven through mysql2 with the same connector option returns 2 for the first statement and drops the table.

Validate one string, execute a different one, and hand the difference to a parser that disagrees with yours about comment syntax. PostgreSQL is not affected by this path, because it treats -- as a comment unconditionally and DBHub executes its split statements individually.

Origin equality is not origin validation

CVE-2026-61742 is the one rated critical, and it is the reason the other two matter to anyone who is not already a DBHub user. In HTTP transport mode — the documented --transport http --port 8080 invocation in the README’s installation block — DBHub exposed /mcp with no authentication token, no per-server secret, and no CSRF-style capability.

The middleware that was supposed to stop browser-origin abuse compared the hostname in Origin to the hostname in Host, rejected only when they differed, then reflected the “validated” origin into Access-Control-Allow-Origin and set Access-Control-Allow-Credentials: true. Its own error string names the threat it was meant to stop: “Origin does not match Host header (DNS rebinding protection).”

Equality is exactly what DNS rebinding preserves. An attacker resolves attacker.example to their own server, serves JavaScript, then rebinds the same hostname to the victim-reachable DBHub address on the same port. Both headers now read attacker.example, the check passes, and the page issues JSON-RPC tool calls with the same authority as an intended MCP client. The advisory is blunt about what this removes from the attack chain: it works “without prompt injection or model involvement.” No agent has to be persuaded of anything. A visited web page reaches the database directly.

This is the third DNS-rebinding failure in MCP tooling we have covered this year, after the GitLab MCP advisories, Google’s MCP Toolbox, and the MCP Java SDK. The pattern is consistent enough to state as a rule: an MCP server that binds beyond loopback and authenticates nothing has delegated its access control to DNS, and DNS was never an access control.

The three combine badly

Read separately, each advisory has a plausible “but the operator would have…” defence. Read together, they close each other’s escape hatches.

Rebinding gives an unauthenticated network or browser caller the ability to invoke execute_sql. The read-only failure means the flag the operator set to contain that caller does not constrain the connection. The classifier that remains is keyword-shaped, so a SELECT wrapping dblink_exec passes. On a privileged DSN, that chain runs from a visited web page to command execution on the database host without a model in the loop at any point.

It is worth naming what makes this class distinct from ordinary web vulnerabilities. A database MCP server exists to be handed broad credentials and pointed at an agent. Its security model is not “prevent SQL injection” — the caller is supposed to send arbitrary SQL. It is “constrain what that SQL can do,” which is the same problem the tool-allowlisting research reached from the defensive side, and the same one the MaxKB shell-tool advisory reached from the framework side: the constraint has to be enforced by something that is not the model and not a pattern-matcher over model output.

The fix, and what it says about placement

Pull request #342 — “enforce read-only mode at the database engine, not just the keyword classifier” — was merged on June 24, touching 13 files with 502 additions and 10 deletions. Its title is the entire lesson. The change moves enforcement per-tool onto the engine: PostgreSQL BEGIN READ ONLY, SQLite query_only, MySQL and MariaDB START TRANSACTION READ ONLY, alongside hardening in allowed-keywords.ts and sql-parser.ts and new integration tests for all four connectors.

Nearly half the diff is tests. That is the right shape for this bug class, because the original failure was not a wrong line of code — the connector code was correct — but a configuration path that never delivered a value to it. Only a test that runs a write against a live read-only connection catches that.

The maintainer note on the canonical advisory also consolidates two duplicate reports (GHSA-7rgf-cwgq-c2qc and GHSA-m689-287g-5xpc) covering the same unwired backstop plus a SQLite-specific gap: the assignment form PRAGMA x = ... was classified read-only when only the query form should be, now additionally guarded with PRAGMA query_only=ON. Three reporters found the same dead branch from three directions.

A disclosure-pipeline note worth flagging

There is a discrepancy in the public record that defenders should be aware of. The repository advisories carry published_at timestamps of June 24, matching the fix. They entered NVD and the GitHub Advisory Database on September 24 — three months later.

More consequentially: at the time of writing, CVE-2026-61789 — the MySQL and MariaDB -- bypass — is queryable in NVD’s API with zero results and does not appear in GitHub’s global advisory database when querying by CVE ID or by affected package. Only CVE-2026-61742 and CVE-2026-61788 are there. The advisory exists and carries a CVE identifier, but it is visible only on the repository’s own advisory page.

If your dependency scanner sources from NVD or the GHSA global database — which is to say, most of them — it can see two of these three issues. Anyone running MySQL or MariaDB behind DBHub, which is precisely the population CVE-2026-61789 affects, gets no signal from the usual feeds. The version floor is the same, so patching resolves it either way; the point is that “no alerts” did not mean “nothing to patch.”

What to do

  • Upgrade to 0.22.6 or later. That release closes all three. Current is 1.3.1 (September 21). CVE-2026-61742 alone is fixed from 0.22.5; the two read-only bypasses require 0.22.6.
  • If you run HTTP transport, treat it as having been unauthenticated. Bind to loopback, put authentication in front of /mcp, and do not rely on the origin check in any pre-0.22.5 build. The rebinding path required no agent involvement, so agent logs will not show it.
  • Do not treat readonly = true as historical evidence of containment. On affected versions it did not create a read-only connection. Scope the exposure by the database role the DSN carried, not by the flag.
  • Check the DSN’s privileges, not just the tool config. Sequence tampering works with ordinary read grants. File read/write and command execution need superuser or pg_read_server_files, pg_write_server_files, pg_execute_server_program, plus dblink for the last two. Enumerate which of those your role actually holds.
  • Enforce read-only in the database, not in the client. A role with no write grants and no access to the dangerous functions survives every bug above. This is the defence that does not depend on a client parser being right.
  • Query CVE-2026-61789 against your own scanner and see whether it returns anything. If it does not, that is a live gap in your MCP dependency coverage, not a sign you are unaffected — and it is worth checking what else in your agent stack is advisory-published but not feed-published.
  • Audit any other MCP server that offers a “read-only” or “safe mode” toggle. The question to ask is where enforcement happens. If the answer is a keyword check over generated SQL or shell strings, it is a filter, not a boundary.

The most useful artifact here is the PR title, not the CVEs: enforce it at the engine, not at the classifier. DBHub’s connectors always contained the correct code; a refactor that moved readonly from the source to the tool left the branch that used it permanently false, and the comment above it kept asserting a guarantee the codebase no longer delivered. Agent infrastructure accumulates exactly this kind of dead safety code, because the features are new, the configuration surface moves fast, and nothing fails loudly when a guardrail quietly stops running. The only reliable detection is a test that tries to do the forbidden thing and expects to be stopped.

Sources: