1- import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
1+ import {
2+ validateDatabaseHost ,
3+ validateSqlWhereClause ,
4+ } from '@/lib/core/security/input-validation.server'
25import type { ClickHouseConnectionConfig } from '@/tools/clickhouse/types'
36
47const REQUEST_TIMEOUT_MS = 30_000
@@ -60,7 +63,8 @@ export interface ClickHouseIntrospectionResult {
6063 */
6164async 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 */
454467function validateWhereClause ( where : string ) : void {
455- const dangerousPatterns = [
456- / ; \s * ( d r o p | d e l e t e | i n s e r t | a l t e r | c r e a t e | t r u n c a t e | r e n a m e | g r a n t | r e v o k e ) / i,
457- / u n i o n \s + ( a l l \s + ) ? s e l e c t / i,
458- / i n t o \s + o u t f i l e / i,
459- / - - / ,
460- / \/ \* / ,
461- / \* \/ / ,
462- / \b o r \s + ( [ ' " ] ? ) ( \w + ) \1\s * = \s * \1\2\1/ i,
463- / \b o r \s + t r u e \b / i,
464- / \b o r \s + f a l s e \b / i,
465- / \b a n d \s + ( [ ' " ] ? ) ( \w + ) \1\s * = \s * \1\2\1/ i,
466- / \b a n d \s + t r u e \b / i,
467- / \b a n d \s + f a l s e \b / i,
468- / \b s l e e p \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 + | t r u e | f a l s e ) \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
0 commit comments