190 lines
5.9 KiB
TypeScript
190 lines
5.9 KiB
TypeScript
import { classifySqlStatementRisk, splitSqlStatementsForSafety, sqlSafetyText, type SqlTextOptions } from "./sql-risk.js";
|
|
|
|
export interface SqlSafetyOptions {
|
|
allowWrites?: boolean;
|
|
allowDangerous?: boolean;
|
|
allowMultipleStatements?: boolean;
|
|
/** Whether `#` starts a line comment (MySQL family only). Default: false. */
|
|
hashLineComments?: boolean;
|
|
}
|
|
|
|
export interface SqlSafetyDecision {
|
|
allowed: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
const DANGEROUS_RISKS = new Set(["ddl", "transaction", "unknown"]);
|
|
|
|
function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
|
if (value === undefined) return undefined;
|
|
const normalized = value.trim().toLowerCase();
|
|
if (normalized === "1" || normalized === "true") return true;
|
|
if (normalized === "0" || normalized === "false") return false;
|
|
return undefined;
|
|
}
|
|
|
|
export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
|
|
const statements = splitSqlStatementsForSafety(sql, options);
|
|
if (statements.length === 0) return { allowed: false, reason: "SQL is empty." };
|
|
if (statements.length > 1 && !options.allowMultipleStatements) {
|
|
return { allowed: false, reason: "Only one SQL statement is allowed per query." };
|
|
}
|
|
|
|
for (let i = 0; i < statements.length; i++) {
|
|
const decision = evaluateSingleSqlStatementSafety(statements[i], options);
|
|
if (!decision.allowed && statements.length > 1) {
|
|
return {
|
|
allowed: false,
|
|
reason: `Statement ${i + 1}: ${decision.reason ?? "SQL blocked."}`,
|
|
};
|
|
}
|
|
if (!decision.allowed) return decision;
|
|
}
|
|
|
|
return { allowed: true };
|
|
}
|
|
|
|
function evaluateSingleSqlStatementSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
|
|
const assessment = classifySqlStatementRisk(sql);
|
|
const firstKeyword = assessment.firstKeyword;
|
|
if (!firstKeyword) return { allowed: false, reason: "SQL statement is not recognized." };
|
|
|
|
if (DANGEROUS_RISKS.has(assessment.risk) && !options.allowDangerous) {
|
|
return { allowed: false, reason: `Dangerous SQL or unrecognized SQL statement "${firstKeyword.toUpperCase()}" is blocked.` };
|
|
}
|
|
|
|
if (!options.allowWrites && assessment.risk !== "read") {
|
|
return {
|
|
allowed: false,
|
|
reason: "MCP SQL execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write statements.",
|
|
};
|
|
}
|
|
|
|
if (options.allowWrites && !options.allowDangerous) {
|
|
const tokens: string[] = sqlSafetyText(sql, options).toLowerCase().match(/[a-z_]+/g) ?? [];
|
|
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 {
|
|
const allowWrites = parseBooleanEnv(env.DBX_MCP_ALLOW_WRITES);
|
|
const allowDangerous = parseBooleanEnv(env.DBX_MCP_ALLOW_DANGEROUS_SQL);
|
|
return {
|
|
allowWrites: allowWrites ?? true,
|
|
allowDangerous: allowDangerous ?? false,
|
|
};
|
|
}
|
|
|
|
export function splitSqlStatements(sql: string, options?: SqlTextOptions): string[] {
|
|
const statements: string[] = [];
|
|
let statementStart = 0;
|
|
let index = 0;
|
|
let state: "none" | "single" | "double" | "backtick" | "bracket" | "lineComment" | "blockComment" | "dollar" = "none";
|
|
let dollarTag = "";
|
|
const hashLineComments = options?.hashLineComments === true;
|
|
|
|
const pushStatement = (end: number) => {
|
|
const statement = sql.slice(statementStart, end).trim();
|
|
if (statement) statements.push(statement);
|
|
};
|
|
|
|
while (index < sql.length) {
|
|
const char = sql[index] ?? "";
|
|
const next = sql[index + 1] ?? "";
|
|
|
|
if (state === "lineComment") {
|
|
if (char === "\n" || char === "\r") state = "none";
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (state === "blockComment") {
|
|
if (char === "*" && next === "/") {
|
|
state = "none";
|
|
index += 2;
|
|
} else {
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (state === "dollar") {
|
|
if (sql.startsWith(dollarTag, index)) {
|
|
index += dollarTag.length;
|
|
state = "none";
|
|
} else {
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (state === "single" || state === "double" || state === "backtick") {
|
|
const quote = state === "single" ? "'" : state === "double" ? '"' : "`";
|
|
if (char === quote) {
|
|
if (next === quote) {
|
|
index += 2;
|
|
continue;
|
|
}
|
|
state = "none";
|
|
} else if (char === "\\" && next) {
|
|
// Preserve dialects that accept backslash escapes without letting an escaped quote end the literal.
|
|
index += 2;
|
|
continue;
|
|
}
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (state === "bracket") {
|
|
if (char === "]") {
|
|
if (next === "]") {
|
|
index += 2;
|
|
continue;
|
|
}
|
|
state = "none";
|
|
}
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (char === "-" && next === "-") {
|
|
state = "lineComment";
|
|
index += 2;
|
|
continue;
|
|
}
|
|
if (hashLineComments && char === "#") {
|
|
state = "lineComment";
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (char === "/" && next === "*") {
|
|
state = "blockComment";
|
|
index += 2;
|
|
continue;
|
|
}
|
|
if (char === "'") state = "single";
|
|
else if (char === '"') state = "double";
|
|
else if (char === "`") state = "backtick";
|
|
else if (char === "[") state = "bracket";
|
|
else if (char === "$") {
|
|
const match = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(index));
|
|
if (match) {
|
|
dollarTag = match[0];
|
|
state = "dollar";
|
|
index += dollarTag.length;
|
|
continue;
|
|
}
|
|
} else if (char === ";") {
|
|
pushStatement(index);
|
|
statementStart = index + 1;
|
|
}
|
|
index += 1;
|
|
}
|
|
|
|
pushStatement(sql.length);
|
|
return statements;
|
|
}
|