Skip to content

Commit 9018fac

Browse files
committed
fix(db): reject comments and unusual quoting in agent SQL and filter every agent table on the server
Agent SQL is checked by a validator and then run by ClickHouse, and the two could read a query differently when it used syntax the validator did not model. Agent SQL now rejects comments, dollar-quoted strings, backslashes outside string literals, and quoted identifiers other than plain names. The server-side tenant filter now covers every agent table instead of only the tables the validator found in the query.
1 parent 5cb1e01 commit 9018fac

3 files changed

Lines changed: 120 additions & 5 deletions

File tree

‎packages/ai/src/ai/tools/execute-sql-query.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
AGENT_SQL_VALIDATION_ERROR,
3+
AGENT_TENANT_COLUMN_BY_TABLE,
34
buildAdditionalTableFilters,
4-
extractAllowlistedTables,
55
validateAgentSQL,
66
} from "@databuddy/db/clickhouse";
77
import { tool } from "ai";
@@ -46,9 +46,8 @@ export async function executeAgentSqlForWebsite({
4646
throw new Error(validation.reason ?? AGENT_SQL_VALIDATION_ERROR);
4747
}
4848

49-
const referencedTables = extractAllowlistedTables(sql);
5049
const additional_table_filters = buildAdditionalTableFilters(
51-
referencedTables,
50+
Object.keys(AGENT_TENANT_COLUMN_BY_TABLE),
5251
websiteId
5352
);
5453

‎packages/db/src/clickhouse/sql-validation.test.ts‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,61 @@ describe("validateAgentSQL", () => {
156156
expect(result).toEqual({ valid: true, reason: null });
157157
});
158158

159+
it("rejects a # comment that hides a second query from the validator", () => {
160+
const result = validateAgentSQL(
161+
`SELECT 'filtered' AS src, count() AS c FROM analytics.events
162+
WHERE client_id = {websiteId:String} AND 1 = 1 # '
163+
UNION ALL SELECT 'all_revenue' AS src, count() AS c FROM analytics.revenue -- '`
164+
);
165+
expect(result.valid).toBe(false);
166+
expect(result.reason).toContain("Comments are not allowed");
167+
});
168+
169+
it("rejects every comment form ClickHouse accepts", () => {
170+
for (const comment of ["# note", "#! note", "-- note", "/* note */"]) {
171+
const result = validateAgentSQL(
172+
`SELECT count() FROM analytics.events ${TENANT} ${comment}`
173+
);
174+
expect(result.valid).toBe(false);
175+
}
176+
});
177+
178+
it("rejects settings hidden behind a comment and quote", () => {
179+
const result = validateAgentSQL(
180+
`SELECT count() FROM analytics.events ${TENANT} # '
181+
SETTINGS additional_table_filters = {} -- '`
182+
);
183+
expect(result.valid).toBe(false);
184+
});
185+
186+
it("rejects quoted identifiers that could hide a quote", () => {
187+
for (const sql of [
188+
`SELECT count() AS \`a'b\` FROM analytics.events ${TENANT}`,
189+
`SELECT count() AS "a'b" FROM analytics.events ${TENANT}`,
190+
`SELECT count() AS \`unclosed FROM analytics.events ${TENANT}`,
191+
]) {
192+
expect(validateAgentSQL(sql).valid).toBe(false);
193+
}
194+
});
195+
196+
it("rejects dollar-quoted strings and unterminated literals", () => {
197+
expect(
198+
validateAgentSQL(`SELECT $$x$$ FROM analytics.events ${TENANT}`).valid
199+
).toBe(false);
200+
expect(
201+
validateAgentSQL(
202+
`SELECT count() FROM analytics.events ${TENANT} AND path = '/a`
203+
).valid
204+
).toBe(false);
205+
});
206+
207+
it("keeps comment and quote characters inside string literals", () => {
208+
const result = validateAgentSQL(
209+
`SELECT count() FROM analytics.events ${TENANT} AND path = '/a#b--c/*d$e"f''g\\'h'`
210+
);
211+
expect(result).toEqual({ valid: true, reason: null });
212+
});
213+
159214
it("is case-insensitive for FROM/JOIN keywords", () => {
160215
const result = validateAgentSQL(
161216
"select count() from analytics.events where client_id = {websiteId:String}"
@@ -363,12 +418,12 @@ describe("validateAgentSQL", () => {
363418
expect(result.valid).toBe(false);
364419
});
365420

366-
it("ignores tenant markers inside comments", () => {
421+
it("rejects tenant markers inside comments", () => {
367422
const result = validateAgentSQL(
368423
"SELECT * FROM analytics.events /* client_id = {websiteId:String} */ WHERE time > now()"
369424
);
370425
expect(result.valid).toBe(false);
371-
expect(result.reason).toContain("client_id");
426+
expect(result.reason).toContain("Comments are not allowed");
372427
});
373428

374429
it("ignores tenant markers inside string literals", () => {

‎packages/db/src/clickhouse/sql-validation.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,63 @@ const WILDCARD_PROJECTION_PATTERN =
185185
/(?:^|,)\s*(?:(?:DISTINCT|ALL)\s+)?(?:[a-zA-Z_][a-zA-Z0-9_]*\s*\.\s*)?\*\s*(?=,|$|\b(?:APPLY|EXCEPT|REPLACE)\b)/i;
186186
const SENSITIVE_PROJECTION_PATTERN = /\b(?:ip|properties|url|user_agent)\b/i;
187187

188+
const PLAIN_QUOTED_IDENTIFIER_PATTERN = /^[A-Za-z0-9_.]+$/;
189+
190+
function agentSqlSyntaxError(sql: string): string | null {
191+
let index = 0;
192+
while (index < sql.length) {
193+
const char = sql[index];
194+
const next = sql[index + 1];
195+
if (char === "'") {
196+
index += 1;
197+
let closed = false;
198+
while (index < sql.length) {
199+
if (sql[index] === "\\") {
200+
index += 2;
201+
continue;
202+
}
203+
if (sql[index] === "'") {
204+
if (sql[index + 1] === "'") {
205+
index += 2;
206+
continue;
207+
}
208+
index += 1;
209+
closed = true;
210+
break;
211+
}
212+
index += 1;
213+
}
214+
if (!closed) {
215+
return "Unterminated string literal.";
216+
}
217+
continue;
218+
}
219+
if (char === "`" || char === '"') {
220+
const end = sql.indexOf(char, index + 1);
221+
if (
222+
end < 0 ||
223+
!PLAIN_QUOTED_IDENTIFIER_PATTERN.test(sql.slice(index + 1, end))
224+
) {
225+
return "Quoted identifiers may contain only letters, digits, underscores and dots.";
226+
}
227+
index = end + 1;
228+
continue;
229+
}
230+
if (
231+
char === "#" ||
232+
(char === "-" && next === "-") ||
233+
(char === "/" && next === "*")
234+
) {
235+
return "Comments are not allowed; remove them and resend the query.";
236+
}
237+
if (char === "$" || char === "\\") {
238+
return `The character ${char} is not allowed outside string literals.`;
239+
}
240+
index += 1;
241+
}
242+
return null;
243+
}
244+
188245
function maskCommentsAndStrings(sql: string): string {
189246
let result = "";
190247
let index = 0;
@@ -446,6 +503,10 @@ export function validateAgentSQL(sql: string): {
446503
valid: boolean;
447504
reason: string | null;
448505
} {
506+
const syntaxError = agentSqlSyntaxError(sql);
507+
if (syntaxError) {
508+
return { valid: false, reason: syntaxError };
509+
}
449510
const sanitized = maskCommentsAndStrings(sql);
450511

451512
if (!SELECT_OR_WITH_PATTERN.test(sanitized)) {

0 commit comments

Comments
 (0)