Skip to content

fix: enforce read-only mode at the engine level for SQL Server#350

Merged
tianzhou merged 5 commits into
mainfrom
fix/sqlserver-readonly-enforcement
Jun 30, 2026
Merged

fix: enforce read-only mode at the engine level for SQL Server#350
tianzhou merged 5 commits into
mainfrom
fix/sqlserver-readonly-enforcement

Conversation

@tianzhou

Copy link
Copy Markdown
Member

Summary

Closes #349 — SQL Server read-only mode bypass via dynamic SQL (EXEC('DELETE ...')).

SQL Server was the only connector without engine-level read-only enforcement. Two fixes:

  • Keyword classifier: Add EXEC and EXECUTE to the SQL Server mutating keyword pattern so dynamic SQL primitives are rejected — both as standalone statements and inside CTEs (e.g. WITH cte AS (SELECT 1) EXEC('DELETE ...')). sp_executesql and xp_cmdshell are always invoked via EXEC so are covered transitively.

  • Connector engine-level backstop: Wrap read-only queries in a sql.Transaction that always rolls back. SQL Server has no BEGIN TRANSACTION READ ONLY, so this unconditional ROLLBACK is the defense-in-depth backstop — even if a classifier bypass reaches the engine, no modifications persist. Matches the pattern used by PostgreSQL (BEGIN READ ONLY), MySQL/MariaDB (START TRANSACTION READ ONLY), and SQLite (PRAGMA query_only = ON).

Test plan

  • 8 new unit tests for EXEC/EXECUTE blocking (standalone, CTE, sp_executesql, multi-statement, string literals, comments)
  • All 79 allowed-keywords tests pass
  • All 793 unit tests pass (5 pre-existing SSH config failures unrelated)
  • Build succeeds (pnpm run build:backend)
  • SQL Server integration test with Docker (pnpm test:integration) — verifies EXEC('DELETE ...') is rolled back under readonly: true

🤖 Generated with Claude Code

SQL Server was the only connector without engine-level read-only
enforcement — it relied entirely on the keyword classifier.

Two changes:

1. Keyword classifier: add EXEC and EXECUTE to the SQL Server mutating
   keyword pattern so dynamic SQL primitives are rejected in CTEs
   (e.g. WITH cte AS (SELECT 1) EXEC('DELETE ...')).

2. Connector: wrap read-only queries in a transaction that always rolls
   back. SQL Server has no BEGIN TRANSACTION READ ONLY, so this
   unconditional ROLLBACK is the defense-in-depth backstop — even if
   a classifier bypass reaches the engine, no modifications persist.

Closes #349

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 30, 2026 06:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR strengthens SQL Server read-only enforcement in DBHub’s execute_sql tool to prevent dynamic SQL from bypassing read-only protections, adding both classifier hardening and an engine-level rollback backstop.

Changes:

  • Add a SQL Server–specific mutating keyword pattern to classify EXEC/EXECUTE usage as non-read-only (including within WITH/CTE statements).
  • Add a SQL Server connector backstop for readonly: true by executing within a transaction that always rolls back.
  • Add unit tests covering SQL Server EXEC/EXECUTE read-only blocking scenarios.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
src/utils/allowed-keywords.ts Adds a SQL Server–specific mutating keyword regex used when validating WITH statements.
src/utils/__tests__/allowed-keywords.test.ts Adds unit tests intended to prevent dynamic-SQL read-only bypasses for SQL Server.
src/connectors/sqlserver/index.ts Adds a read-only execution path that wraps queries in a transaction and always rolls back.

Comment thread src/utils/allowed-keywords.ts
Comment thread src/connectors/sqlserver/index.ts Outdated
Comment thread src/connectors/sqlserver/index.ts
Comment thread src/utils/__tests__/allowed-keywords.test.ts
tianzhou and others added 2 commits June 29, 2026 23:36
The executeReadOnly() rollback guard is bypassable if the user's SQL
contains COMMIT — it ends the outer transaction before the finally-block
ROLLBACK runs. Scan the stripped SQL for COMMIT/ROLLBACK and reject
before opening the transaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…gging

Address Copilot review:
- Add sp_executesql and xp_cmdshell to SQL Server mutating keyword
  pattern (callable without EXEC as first statement in a batch)
- Add parameter-aware error logging to executeReadOnly() matching
  the non-readonly path
- Add tests for implicit sp_executesql and xp_cmdshell in CTEs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/connectors/sqlserver/index.ts
EXEC('COMMIT') would bypass the COMMIT/ROLLBACK keyword guard because
stripCommentsAndStrings removes the string content. Block EXEC, EXECUTE,
sp_executesql, and xp_cmdshell at the connector level so dynamic SQL
carrying hidden transaction control cannot escape the rollback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/connectors/sqlserver/index.ts Outdated
Track whether the query itself failed so the finally block can
distinguish a rollback error after a successful query (dangerous —
data may have persisted) from one after a query error (safe to
suppress so the original error propagates). Also removes the
redundant outer try block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@tianzhou tianzhou left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed Copilot review comment (rollback error handling) in 46e803d.

The finally block now tracks whether the query itself failed via a queryFailed flag:

  • Query succeeded + rollback fails: throws "Read-only rollback failed — data may have been modified: ..." — caller is alerted that the read-only guarantee could not be verified.
  • Query failed + rollback fails: suppresses the rollback error so the original query error propagates unchanged.

Also removed the redundant outer try block that had no catch/finally after the restructuring.

@tianzhou tianzhou merged commit 08e522c into main Jun 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] SQL Server read-only mode bypass via dynamic SQL in execute_sql

2 participants