GHSA-mwwr-p57h-56pf: SQL Injection

Published Sep 24, 2026
·
Updated

Summary Setting readonly = true on the executesql tool does not make the connection read-only. The connectors are written to set PostgreSQL defaulttransactionreadonly=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 (loexport), reading arbitrary host files (pgreadfile), 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): ts // SDK-level readonly enforcement: Set defaulttransactionreadonly for the entire connection if (config?.readonly) { poolConfig.options = (poolConfig.options || '') + ' -c defaulttransactionreadonly=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): ts // Pass readonly flag for SDK-level enforcement (PostgreSQL, SQLite) if (source.readonly !== undefined) { config.readonly = source.readonly; } source.readonly can never have a value: - SourceConfig has no readonly field (src/types/config.ts:49-62). readonly exists only on the per-tool ExecuteSqlToolConfig / CustomToolConfig. - The TOML loader rejects readonly at source level (src/config/toml-loader.ts:476-481: "readonly must be configured per-tool, not per-source"). - The --readonly CLI 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 loexport(lo, '/path') — writes a file on the server. Needs superuser or pgwriteserverfiles. - SELECT pgreadfile('/etc/passwd') — reads any file the server user can read. Needs superuser or pgreadserverfiles. - SELECT dblinkexec('dbname=...', 'UPDATE ...') — opens a fresh connection (not read-only) and runs writes/DDL. Needs the dblink extension. - SELECT dblinkexec('dbname=...', $$COPY (SELECT 1) TO PROGRAM 'id'$$) — command execution. Needs superuser or pgexecuteserverprogram, plus dblink. The read-only test suite covers none of these. PoC Point DBHub at a PostgreSQL source with read-only set on the tool: toml [[sources]] id = "default" dsn = "postgres://app:app@localhost:5432/app" [[tools]] name = "executesql" source = "default" readonly = true Start it and call executesql: npx @bytebase/dbhub@latest --transport http --port 8080 With any role, a write that should be blocked goes through — the sequence value changes and the call returns success: sql SELECT setval('usersidseq', 1); With a privileged role, the rest are also accepted and executed: sql SELECT loexport(lofrombytea(0, decode('48656c6c6f0a','hex')), '/tmp/dbhubpoc'); -- writes /tmp/dbhubpoc SELECT pgreadfile('/etc/passwd'); -- reads a host file SELECT dblinkexec('dbname=app', 'UPDATE users SET admin=true'); -- write via a new connection SELECT dblinkexec('dbname=app', $$COPY (SELECT 1) TO PROGRAM 'id > /tmp/pwned'$$); -- runs a shell command The 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 executesql 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 PRAGMA gap. - GHSA-m689-287g-5xpc — SQLite assignment-form PRAGMA write 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 queryonly=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 queryonly, MySQL/MariaDB START TRANSACTION READ ONLY) plus the classifier hardening above.

Affected Software

1 affected componentFixes available
npm/@bytebase/dbhub<0.22.6
0.22.6

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/@bytebase/dbhub to a version that resolves this vulnerability.

    Fixed in 0.22.6
  2. Upgrade

    Upgrade to a fixed release to a version that resolves this vulnerability.

    Patch pull/342

Event History

Sep 24, 2026
Advisory Published
via GitHub·07:37 PM
Data Sourced
via GitHub·07:37 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which deployments are most exposed?

Any deployment exposing the HTTP transport is reachable by network callers of /mcp because it is unauthenticated and binds to 0.0.0.0 by default. This makes exposure dependent on network reachability to that endpoint rather than possession of database credentials.

2

What database access does an attacker need?

An attacker can reach the database through the execute_sql tool by sending requests to the exposed /mcp endpoint. The impact then depends on the privileges of the database role used by the connection.

3

How does the impact differ by database role?

With an ordinary PostgreSQL role, an attacker can tamper with sequences through a SELECT statement that invokes a side-effecting function. With a privileged role, they may write server files using lo_export, read host files using pg_read_file, or achieve remote code execution using dblink and COPY ... TO PROGRAM.

4

Does setting readonly=true provide database-enforced protection?

No. The code intended to enable PostgreSQL default_transaction_read_only or SQLite readOnly mode is gated on a config value that is never populated, so it does not run. Enforcement is limited to checking the first keyword of a statement, allowing SELECT statements with side effects to bypass it.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203