Skip to content

Commit ad2e669

Browse files
tianzhouclaude
andcommitted
fix: add sp_executesql/xp_cmdshell to mutating keywords, add error logging
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>
1 parent da17b9a commit ad2e669

3 files changed

Lines changed: 26 additions & 6 deletions

File tree

src/connectors/sqlserver/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,17 @@ export class SQLServerConnector implements Connector {
764764
});
765765
}
766766

767-
const result = await request.query(processedSQL);
767+
let result;
768+
try {
769+
result = await request.query(processedSQL);
770+
} catch (error) {
771+
console.error(`[SQL Server executeReadOnly] ERROR: ${(error as Error).message}`);
772+
console.error(`[SQL Server executeReadOnly] SQL: ${processedSQL}`);
773+
if (parameters && parameters.length > 0) {
774+
console.error(`[SQL Server executeReadOnly] Parameters: ${JSON.stringify(parameters)}`);
775+
}
776+
throw error;
777+
}
768778
return {
769779
rows: result.recordset || [],
770780
rowCount: result.rowsAffected[0] || 0,

src/utils/__tests__/allowed-keywords.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,14 @@ describe("isReadOnlySQL", () => {
263263
expect(isReadOnlySQL("WITH cte AS (SELECT 1) EXECUTE('DELETE FROM users')", "sqlserver")).toBe(false);
264264
});
265265

266+
it("should reject implicit sp_executesql (no EXEC prefix) inside a CTE", () => {
267+
expect(isReadOnlySQL("WITH cte AS (SELECT 1) sp_executesql N'DELETE FROM users'", "sqlserver")).toBe(false);
268+
});
269+
270+
it("should reject xp_cmdshell inside a CTE", () => {
271+
expect(isReadOnlySQL("WITH cte AS (SELECT 1) xp_cmdshell 'del *.*'", "sqlserver")).toBe(false);
272+
});
273+
266274
it("should not reject EXEC/EXECUTE inside string literals", () => {
267275
expect(isReadOnlySQL("SELECT * FROM users WHERE name = 'EXEC is a keyword'", "sqlserver")).toBe(true);
268276
});

src/utils/allowed-keywords.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,15 @@ const mutatingPatternWithReplace = new RegExp(
5151
);
5252

5353
/**
54-
* Extended pattern for SQL Server: adds EXEC/EXECUTE which are T-SQL dynamic
55-
* SQL primitives that can run arbitrary (including mutating) statements.
56-
* sp_executesql and xp_cmdshell are always invoked via EXEC so are covered
57-
* transitively.
54+
* Extended pattern for SQL Server: adds T-SQL dynamic SQL primitives that can
55+
* run arbitrary (including mutating) statements.
56+
* - EXEC/EXECUTE: direct dynamic SQL execution
57+
* - sp_executesql: system proc for parameterized dynamic SQL (callable without
58+
* EXEC as the first statement in a batch)
59+
* - xp_cmdshell: OS command execution
5860
*/
5961
const mutatingPatternSqlServer = new RegExp(
60-
`\\b(?:${[...mutatingKeywords, "execute", "exec"].join("|")})\\b`,
62+
`\\b(?:${[...mutatingKeywords, "execute", "exec", "sp_executesql", "xp_cmdshell"].join("|")})\\b`,
6163
"i",
6264
);
6365

0 commit comments

Comments
 (0)