Skip to content

Commit 800f56f

Browse files
authored
fix(clickhouse): harden read-only query enforcement and centralize WHERE-clause validation (#4895)
* fix(clickhouse): centralize WHERE-clause validation in input-validation and harden tautology detection * fix(clickhouse): enforce server-side readonly=1 on the query operation * fix(clickhouse): allow BETWEEN bounds in WHERE validation (OR-only literal rule) and dedupe JSDoc
1 parent 24a6086 commit 800f56f

2 files changed

Lines changed: 127 additions & 32 deletions

File tree

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

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
1+
import {
2+
validateDatabaseHost,
3+
validateSqlWhereClause,
4+
} from '@/lib/core/security/input-validation.server'
25
import type { ClickHouseConnectionConfig } from '@/tools/clickhouse/types'
36

47
const REQUEST_TIMEOUT_MS = 30_000
@@ -60,7 +63,8 @@ export interface ClickHouseIntrospectionResult {
6063
*/
6164
async function clickhouseRequest(
6265
config: ClickHouseConnectionConfig,
63-
statement: string
66+
statement: string,
67+
options: { readOnly?: boolean } = {}
6468
): Promise<ClickHouseHttpResult> {
6569
const hostValidation = await validateDatabaseHost(config.host, 'host')
6670
if (!hostValidation.isValid) {
@@ -70,6 +74,12 @@ async function clickhouseRequest(
7074
const protocol = config.secure ? 'https' : 'http'
7175
const url = new URL(`${protocol}://${config.host}:${config.port}/`)
7276
url.searchParams.set('database', config.database)
77+
if (options.readOnly) {
78+
// Server-enforced read-only: ClickHouse rejects any write/DDL and forbids the
79+
// query from re-enabling writes via `SET readonly=0`. This is the real boundary
80+
// for the query operation; the SQL-shape checks below are defense-in-depth.
81+
url.searchParams.set('readonly', '1')
82+
}
7383

7484
const controller = new AbortController()
7585
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
@@ -290,7 +300,9 @@ export async function executeClickHouseQuery(
290300
)
291301
}
292302
}
293-
const result = await clickhouseRequest(config, ensureJsonFormat(query))
303+
const result = await clickhouseRequest(config, ensureJsonFormat(query), {
304+
readOnly: options.enforceReadOnly,
305+
})
294306
return parseRowsResult(result)
295307
}
296308

@@ -448,37 +460,14 @@ function sanitizeSingleIdentifier(identifier: string): string {
448460
}
449461

450462
/**
451-
* Rejects WHERE clauses containing patterns commonly used in SQL injection so
452-
* that user-supplied conditions cannot escape the intended mutation.
463+
* Rejects WHERE clauses containing SQL-injection or always-true tautology
464+
* patterns so user-supplied conditions cannot broaden a mutation to every row.
465+
* Delegates to the shared {@link validateSqlWhereClause} guard (defense-in-depth).
453466
*/
454467
function validateWhereClause(where: string): void {
455-
const dangerousPatterns = [
456-
/;\s*(drop|delete|insert|alter|create|truncate|rename|grant|revoke)/i,
457-
/union\s+(all\s+)?select/i,
458-
/into\s+outfile/i,
459-
/--/,
460-
/\/\*/,
461-
/\*\//,
462-
/\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
463-
/\bor\s+true\b/i,
464-
/\bor\s+false\b/i,
465-
/\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
466-
/\band\s+true\b/i,
467-
/\band\s+false\b/i,
468-
/\bsleep\s*\(/i,
469-
/;\s*\w+/,
470-
// Constant / tautological conditions that don't reference columns and would
471-
// broaden a mutation to all rows (e.g. "1=1", "1 < 2", "'a'='a'", bare "1"/"true").
472-
/\b\d+\s*(?:=|==|<>|!=|<=|>=|<|>)\s*\d+\b/,
473-
/(['"])([^'"]*)\1\s*(?:=|==|<>|!=)\s*\1\2\1/,
474-
/\b(\w+)\s*=\s*\1\b/i,
475-
/^\s*(?:\d+|true|false)\s*$/i,
476-
]
477-
478-
for (const pattern of dangerousPatterns) {
479-
if (pattern.test(where)) {
480-
throw new Error('WHERE clause contains potentially dangerous operation')
481-
}
468+
const result = validateSqlWhereClause(where, 'WHERE clause')
469+
if (!result.isValid) {
470+
throw new Error(result.error)
482471
}
483472
}
484473

apps/sim/lib/core/security/input-validation.server.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,112 @@ export async function validateDatabaseHost(
206206
}
207207
}
208208

209+
/**
210+
* Patterns run against the WHERE clause with string/identifier literals masked
211+
* out (so an attacker cannot smuggle `OR 1` or `; DROP` inside a quoted value).
212+
*
213+
* The connector-literal rules below are intentionally `OR`-only: only an
214+
* `OR <truthy>` term broadens a mutation to every row. `AND <number>` is a no-op
215+
* for broadening and is also exactly what `BETWEEN low AND high` produces, so
216+
* matching it would reject legitimate range filters (e.g. `id BETWEEN 1 AND 10`).
217+
*/
218+
const SQL_WHERE_MASKED_PATTERNS: readonly RegExp[] = [
219+
/;\s*\w/, // stacked statement
220+
/\bunion\s+(?:all\s+)?select\b/i,
221+
/\binto\s+(?:out|dump)file\b/i,
222+
/--/,
223+
/\/\*/,
224+
/\*\//,
225+
/\b(?:sleep|pg_sleep|benchmark)\s*\(/i,
226+
/\b(\w+)\s*=\s*\1\b/i, // same (unquoted) operand both sides: x=x, 1=1
227+
/\b\d+(?:\.\d+)?\s*(?:=|==|<>|!=|<=|>=|<|>)\s*\d+(?:\.\d+)?\b/, // constant vs constant: 1=1, 1<2, 2>1
228+
/\bor\s+(?:true|false)\b/i, // OR TRUE / OR FALSE
229+
/\bor\s+\d+(?:\.\d+)?\b(?!\s*[=<>!+\-*/%])/i, // standalone truthy literal after OR: OR 1, OR 42
230+
/^\s*(?:\d+(?:\.\d+)?|true|false)\s*$/i, // bare constant: "1" / "true" / "false"
231+
]
232+
233+
/**
234+
* Patterns run against the raw WHERE clause (need the literal contents intact),
235+
* e.g. equality between two identical string literals.
236+
*/
237+
const SQL_WHERE_RAW_PATTERNS: readonly RegExp[] = [
238+
/(['"])([^'"]*)\1\s*(?:=|==|<>|!=)\s*\1\2\1/, // 'a'='a' / "x"="x"
239+
]
240+
241+
/**
242+
* Replaces the contents of string literals ('...'), double-quoted and
243+
* backtick-quoted identifiers with spaces (preserving length) so structural
244+
* scans do not treat data inside quotes as SQL. Comments are intentionally left
245+
* intact so comment-injection sequences are still detected.
246+
*/
247+
function maskSqlStringLiterals(sql: string): string {
248+
let out = ''
249+
let i = 0
250+
while (i < sql.length) {
251+
const ch = sql[i]
252+
if (ch === "'" || ch === '"' || ch === '`') {
253+
out += ' '
254+
i++
255+
while (i < sql.length && sql[i] !== ch) {
256+
if (ch !== '`' && sql[i] === '\\') {
257+
out += ' '
258+
i += 2
259+
continue
260+
}
261+
out += ' '
262+
i++
263+
}
264+
if (i < sql.length) {
265+
out += ' '
266+
i++
267+
}
268+
continue
269+
}
270+
out += ch
271+
i++
272+
}
273+
return out
274+
}
275+
276+
/**
277+
* Validates a free-form SQL `WHERE` condition for injection and always-true
278+
* tautology patterns. Returns a {@link ValidationResult}; callers decide whether
279+
* to throw or surface the error.
280+
*
281+
* IMPORTANT: this is **defense-in-depth, not a security boundary**. A free-form
282+
* SQL condition cannot be exhaustively validated against every always-true
283+
* expression (e.g. `OR 2 > 1`, `OR (1)`, `OR NOT 0`, `OR length(x) >= 0`). The
284+
* real boundary is that the caller supplies their own database credentials and
285+
* could run equivalent SQL directly (e.g. via a raw-SQL/execute operation). This
286+
* guard stops the easy, obvious ways an injected condition broadens a mutation
287+
* to every row; it is not a substitute for constraining untrusted input upstream.
288+
*
289+
* @param where - The WHERE clause condition (without the `WHERE` keyword)
290+
* @param paramName - Label used in the error message
291+
*/
292+
export function validateSqlWhereClause(
293+
where: string | null | undefined,
294+
paramName = 'WHERE clause'
295+
): ValidationResult {
296+
if (typeof where !== 'string' || where.trim().length === 0) {
297+
return { isValid: false, error: `${paramName} is required` }
298+
}
299+
300+
const masked = maskSqlStringLiterals(where)
301+
const matched =
302+
SQL_WHERE_MASKED_PATTERNS.some((pattern) => pattern.test(masked)) ||
303+
SQL_WHERE_RAW_PATTERNS.some((pattern) => pattern.test(where))
304+
305+
if (matched) {
306+
return {
307+
isValid: false,
308+
error: `${paramName} contains a disallowed or always-true expression`,
309+
}
310+
}
311+
312+
return { isValid: true }
313+
}
314+
209315
export interface SecureFetchOptions {
210316
method?: string
211317
headers?: Record<string, string>

0 commit comments

Comments
 (0)