Skip to content

Commit f3341fe

Browse files
tianzhouclaude
andcommitted
Harden SQL Server EXPLAIN translation (PR review)
Address Copilot review feedback on the EXPLAIN/SHOWPLAN flow: - Skip leading whitespace and SQL comments when detecting EXPLAIN, so a comment-prefixed EXPLAIN that passes the (comment-stripping) read-only validator is also translated instead of reaching the server untranslated. - Trim the extracted inner query. - Reject empty EXPLAIN and any SET SHOWPLAN inside the explained statement. SQL Server already blocks SET SHOWPLAN alongside other statements in a batch (verified: the DELETE does not execute), but this keeps the read-only guarantee self-contained rather than relying on server behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 950b28f commit f3341fe

2 files changed

Lines changed: 61 additions & 6 deletions

File tree

src/connectors/__tests__/sqlserver.integration.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,41 @@ describe('SQL Server Connector Integration Tests', () => {
298298
expect(Number(after.rows[0].count)).toBe(0);
299299
});
300300

301+
it('should translate EXPLAIN even when preceded by a comment', async () => {
302+
const result = await sqlServerTest.connector.executeSQL(
303+
'/* inspect plan */ EXPLAIN SELECT * FROM users',
304+
{}
305+
);
306+
expect(result.rows).toHaveLength(1);
307+
expect(result.rows[0].plan as string).toContain('ShowPlanXML');
308+
});
309+
310+
it('should reject SET SHOWPLAN smuggled into an EXPLAIN query', async () => {
311+
const before = await sqlServerTest.connector.executeSQL(
312+
'SELECT COUNT(*) as count FROM users',
313+
{}
314+
);
315+
316+
await expect(
317+
sqlServerTest.connector.executeSQL(
318+
'EXPLAIN SET SHOWPLAN_XML OFF DELETE FROM users',
319+
{}
320+
)
321+
).rejects.toThrow(/SET SHOWPLAN/i);
322+
323+
const after = await sqlServerTest.connector.executeSQL(
324+
'SELECT COUNT(*) as count FROM users',
325+
{}
326+
);
327+
expect(Number(after.rows[0].count)).toBe(Number(before.rows[0].count));
328+
});
329+
330+
it('should reject an empty EXPLAIN', async () => {
331+
await expect(
332+
sqlServerTest.connector.executeSQL('EXPLAIN ', {})
333+
).rejects.toThrow(/requires a statement/i);
334+
});
335+
301336
it('should handle SQL Server IDENTITY columns', async () => {
302337
await sqlServerTest.connector.executeSQL(`
303338
CREATE TABLE identity_test (

src/connectors/sqlserver/index.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { isDriverNotInstalled } from "../../utils/module-loader.js";
1515
import { SafeURL } from "../../utils/safe-url.js";
1616
import { obfuscateDSNPassword } from "../../utils/dsn-obfuscate.js";
1717
import { SQLRowLimiter } from "../../utils/sql-row-limiter.js";
18+
import { stripCommentsAndStrings } from "../../utils/sql-parser.js";
1819

1920
/**
2021
* SQL Server DSN parser
@@ -171,8 +172,13 @@ export class SQLServerConnector implements Connector {
171172
// Source ID is set by ConnectorManager after cloning
172173
private sourceId: string = "default";
173174

174-
/** Leading `EXPLAIN ` keyword, translated to a SHOWPLAN_XML request. */
175-
private static readonly EXPLAIN_PREFIX = /^\s*explain\s+/i;
175+
/**
176+
* Leading whitespace and SQL comments to skip before looking for a keyword.
177+
* The read-only validator strips comments before checking the first keyword,
178+
* so the connector must skip them too; otherwise an EXPLAIN preceded by a
179+
* comment passes validation but reaches the server untranslated.
180+
*/
181+
private static readonly LEADING_NOISE = /^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/;
176182

177183
getId(): string {
178184
return this.sourceId;
@@ -580,10 +586,12 @@ export class SQLServerConnector implements Connector {
580586
// SQL Server has no native EXPLAIN statement. Translate a leading `EXPLAIN`
581587
// into a SHOWPLAN_XML request so callers get a Postgres/MySQL-like
582588
// experience. SHOWPLAN_XML compiles the statement without executing it, so
583-
// this is always read-only safe.
584-
const explainMatch = sqlQuery.match(SQLServerConnector.EXPLAIN_PREFIX);
585-
if (explainMatch) {
586-
return this.explainQuery(sqlQuery.slice(explainMatch[0].length));
589+
// this is read-only safe (further enforced in explainQuery).
590+
const afterNoise = sqlQuery.slice(
591+
sqlQuery.match(SQLServerConnector.LEADING_NOISE)![0].length
592+
);
593+
if (/^explain\b/i.test(afterNoise)) {
594+
return this.explainQuery(afterNoise.slice("explain".length).trim());
587595
}
588596

589597
try {
@@ -658,6 +666,18 @@ export class SQLServerConnector implements Connector {
658666
* with SHOWPLAN enabled (which would return a plan instead of its results).
659667
*/
660668
private async explainQuery(innerQuery: string): Promise<SQLResult> {
669+
if (!innerQuery) {
670+
throw new Error("EXPLAIN requires a statement to analyze");
671+
}
672+
673+
// Defense in depth: the SET SHOWPLAN session toggle is what makes EXPLAIN
674+
// non-executing, so the explained statement must not disable it. SQL Server
675+
// already rejects `SET SHOWPLAN_* OFF` alongside other statements in a
676+
// batch, but enforcing it here keeps the read-only guarantee self-contained.
677+
if (/\bset\s+showplan/i.test(stripCommentsAndStrings(innerQuery, "sqlserver"))) {
678+
throw new Error("EXPLAIN does not support SET SHOWPLAN statements");
679+
}
680+
661681
if (!this.config) {
662682
throw new Error("Not connected to SQL Server database");
663683
}

0 commit comments

Comments
 (0)