Skip to content

Commit 3dec257

Browse files
committed
fix(clickhouse): enforce read-only query operation and harden WHERE-clause guard
1 parent a4f805f commit 3dec257

2 files changed

Lines changed: 22 additions & 3 deletions

File tree

apps/sim/app/api/tools/clickhouse/query/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2828
`[${requestId}] Executing ClickHouse query on ${params.host}:${params.port}/${params.database}`
2929
)
3030

31-
const result = await executeClickHouseQuery(params, params.query)
31+
const result = await executeClickHouseQuery(params, params.query, { enforceReadOnly: true })
3232

3333
logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`)
3434

apps/sim/app/api/tools/clickhouse/utils.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ function parseRowsResult(result: ClickHouseHttpResult): ClickHouseRowsResult {
131131
return { rows: [], rowCount: written || read || 0 }
132132
}
133133

134+
/** Read-only statement leaders that return a result set and never mutate data. */
135+
const READ_ONLY_STATEMENT = /^(select|with|show|describe|desc|explain|exists)\b/i
136+
134137
/**
135138
* Appends `FORMAT JSON` to read statements that do not already specify an output
136139
* format, so the HTTP interface returns a structured result set.
@@ -140,16 +143,26 @@ function ensureJsonFormat(query: string): string {
140143
if (/\bformat\s+[a-z0-9_]+$/i.test(trimmed)) {
141144
return trimmed
142145
}
143-
if (/^(select|with|show|describe|desc|explain|exists)\b/i.test(trimmed)) {
146+
if (READ_ONLY_STATEMENT.test(trimmed)) {
144147
return `${trimmed}\nFORMAT JSON`
145148
}
146149
return trimmed
147150
}
148151

149152
export async function executeClickHouseQuery(
150153
config: ClickHouseConnectionConfig,
151-
query: string
154+
query: string,
155+
options: { enforceReadOnly?: boolean } = {}
152156
): Promise<ClickHouseRowsResult> {
157+
if (options.enforceReadOnly) {
158+
// Strip leading parens so wrapped selects like "(SELECT ...)" still validate.
159+
const leader = query.trim().replace(/^\(+\s*/, '')
160+
if (!READ_ONLY_STATEMENT.test(leader)) {
161+
throw new Error(
162+
'The query operation only allows read-only statements (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN, EXISTS). Use the Execute Raw SQL operation to run writes or DDL.'
163+
)
164+
}
165+
}
153166
const result = await clickhouseRequest(config, ensureJsonFormat(query))
154167
return parseRowsResult(result)
155168
}
@@ -327,6 +340,12 @@ function validateWhereClause(where: string): void {
327340
/\band\s+false\b/i,
328341
/\bsleep\s*\(/i,
329342
/;\s*\w+/,
343+
// Constant / tautological conditions that don't reference columns and would
344+
// broaden a mutation to all rows (e.g. "1=1", "1 < 2", "'a'='a'", bare "1"/"true").
345+
/\b\d+\s*(?:=|==|<>|!=|<=|>=|<|>)\s*\d+\b/,
346+
/(['"])([^'"]*)\1\s*(?:=|==|<>|!=)\s*\1\2\1/,
347+
/\b(\w+)\s*=\s*\1\b/i,
348+
/^\s*(?:\d+|true|false)\s*$/i,
330349
]
331350

332351
for (const pattern of dangerousPatterns) {

0 commit comments

Comments
 (0)