dbx/packages/node-core/src/sql-safety.ts

116 lines
3.6 KiB
TypeScript

export interface SqlSafetyOptions {
allowWrites?: boolean;
allowDangerous?: boolean;
}
export interface SqlSafetyDecision {
allowed: boolean;
reason?: string;
}
const READ_KEYWORDS = new Set(["select", "with", "show", "describe", "desc", "explain"]);
const DANGEROUS_KEYWORDS = new Set(["drop", "truncate", "alter"]);
export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
const statements = splitSqlStatements(sql);
if (statements.length === 0) return { allowed: false, reason: "SQL is empty." };
if (statements.length > 1) return { allowed: false, reason: "Only one SQL statement is allowed per MCP query." };
const normalized = stripSqlCommentsAndStrings(statements[0]).trim();
const firstKeyword = normalized.match(/^[a-zA-Z_]+/)?.[0]?.toLowerCase();
if (!firstKeyword) return { allowed: false, reason: "SQL statement is not recognized." };
const tokens: string[] = normalized.toLowerCase().match(/[a-z_]+/g) ?? [];
const dangerous = tokens.find((token) => DANGEROUS_KEYWORDS.has(token));
if (dangerous && !options.allowDangerous) {
return { allowed: false, reason: `Dangerous SQL keyword "${dangerous.toUpperCase()}" is blocked.` };
}
if (!options.allowWrites && !READ_KEYWORDS.has(firstKeyword)) {
return {
allowed: false,
reason: "MCP SQL execution is read-only by default. Set DBX_MCP_ALLOW_WRITES=1 to allow write statements.",
};
}
if (options.allowWrites && !options.allowDangerous) {
if (firstKeyword === "update" && !tokens.includes("where")) {
return { allowed: false, reason: "UPDATE statements must include a WHERE clause." };
}
if (firstKeyword === "delete" && !tokens.includes("where")) {
return { allowed: false, reason: "DELETE statements must include a WHERE clause." };
}
}
return { allowed: true };
}
export function sqlSafetyFromEnv(env: NodeJS.ProcessEnv = process.env): SqlSafetyOptions {
return {
allowWrites: env.DBX_MCP_ALLOW_WRITES === "1" || env.DBX_MCP_ALLOW_WRITES === "true",
allowDangerous: env.DBX_MCP_ALLOW_DANGEROUS_SQL === "1" || env.DBX_MCP_ALLOW_DANGEROUS_SQL === "true",
};
}
function splitSqlStatements(sql: string): string[] {
const statements: string[] = [];
let current = "";
let quote: "'" | '"' | "`" | null = null;
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < sql.length; i++) {
const char = sql[i];
const next = sql[i + 1];
if (inLineComment) {
current += char;
if (char === "\n") inLineComment = false;
continue;
}
if (inBlockComment) {
current += char;
if (char === "*" && next === "/") {
current += next;
i++;
inBlockComment = false;
}
continue;
}
if (quote) {
current += char;
if (char === quote) {
if (next === quote) {
current += next;
i++;
} else {
quote = null;
}
}
continue;
}
if (char === "-" && next === "-") inLineComment = true;
if (char === "/" && next === "*") inBlockComment = true;
if (char === "'" || char === '"' || char === "`") quote = char;
if (char === ";") {
if (current.trim()) statements.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current.trim()) statements.push(current.trim());
return statements;
}
function stripSqlCommentsAndStrings(sql: string): string {
return sql
.replace(/--.*$/gm, " ")
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/'([^']|'')*'/g, "''")
.replace(/"([^"]|"")*"/g, '""')
.replace(/`([^`]|``)*`/g, "``");
}