@@ -9,6 +9,13 @@ import { FilterOperators } from './types';
99import { applyPlugins } from './utils' ;
1010import { mapScreenResolutionToDeviceType , type DeviceType } from './screen-resolution-to-device-type' ;
1111
12+ // Constants for special filter fields to prevent typos
13+ const SPECIAL_FILTER_FIELDS = {
14+ PATH : 'path' ,
15+ REFERRER : 'referrer' ,
16+ DEVICE_TYPE : 'device_type' ,
17+ } as const ;
18+
1219export class SimpleQueryBuilder {
1320 private config : SimpleQueryConfig ;
1421 private request : QueryRequest ;
@@ -43,9 +50,9 @@ export class SimpleQueryBuilder {
4350 . map ( ( [ resolution , _ ] ) => `'${ resolution } '` )
4451 . join ( ', ' ) ;
4552
46- // SQL for parsing resolution dimensions
47- const widthExpr = "toFloat64(substring(screen_resolution, 1, position(screen_resolution, 'x') - 1))" ;
48- const heightExpr = "toFloat64(substring(screen_resolution, position(screen_resolution, 'x') + 1))" ;
53+ // SQL for parsing resolution dimensions with error handling
54+ const widthExpr = "toFloat64(if(position(screen_resolution, 'x') > 0, substring(screen_resolution, 1, position(screen_resolution, 'x') - 1), NULL ))" ;
55+ const heightExpr = "toFloat64(if(position(screen_resolution, 'x') > 0, substring(screen_resolution, position(screen_resolution, 'x') + 1), NULL ))" ;
4956 const longSideExpr = `greatest(${ widthExpr } , ${ heightExpr } )` ;
5057 const shortSideExpr = `least(${ widthExpr } , ${ heightExpr } )` ;
5158 const aspectExpr = `${ longSideExpr } / ${ shortSideExpr } ` ;
@@ -54,17 +61,17 @@ export class SimpleQueryBuilder {
5461 const heuristicCondition = ( ( ) => {
5562 switch ( deviceType ) {
5663 case 'mobile' :
57- return `(${ shortSideExpr } <= 480)` ;
64+ return `(${ shortSideExpr } <= 480 AND ${ shortSideExpr } IS NOT NULL )` ;
5865 case 'tablet' :
59- return `(${ shortSideExpr } <= 900 AND ${ shortSideExpr } > 480)` ;
66+ return `(${ shortSideExpr } <= 900 AND ${ shortSideExpr } > 480 AND ${ shortSideExpr } IS NOT NULL )` ;
6067 case 'laptop' :
61- return `(${ longSideExpr } <= 1600 AND ${ shortSideExpr } > 900)` ;
68+ return `(${ longSideExpr } <= 1600 AND ${ shortSideExpr } > 900 AND ${ longSideExpr } IS NOT NULL )` ;
6269 case 'desktop' :
63- return `(${ longSideExpr } <= 3000 AND ${ longSideExpr } > 1600)` ;
70+ return `(${ longSideExpr } <= 3000 AND ${ longSideExpr } > 1600 AND ${ longSideExpr } IS NOT NULL )` ;
6471 case 'ultrawide' :
65- return `(${ aspectExpr } >= 2.0 AND ${ longSideExpr } >= 2560)` ;
72+ return `(${ aspectExpr } >= 2.0 AND ${ longSideExpr } >= 2560 AND ${ longSideExpr } IS NOT NULL )` ;
6673 case 'watch' :
67- return `(${ longSideExpr } <= 400 AND ${ aspectExpr } >= 0.85 AND ${ aspectExpr } <= 1.15)` ;
74+ return `(${ longSideExpr } <= 400 AND ${ aspectExpr } >= 0.85 AND ${ aspectExpr } <= 1.15 AND ${ longSideExpr } IS NOT NULL )` ;
6875 case 'unknown' :
6976 default :
7077 return '1 = 0' ; // Never matches
@@ -89,7 +96,7 @@ export class SimpleQueryBuilder {
8996 const operator = FilterOperators [ filter . op ] ;
9097
9198 // Special handling for path filters - apply same normalization as used in queries
92- if ( filter . field === 'path' ) {
99+ if ( filter . field === SPECIAL_FILTER_FIELDS . PATH ) {
93100 const normalizedPathExpression = "CASE WHEN trimRight(path(path), '/') = '' THEN '/' ELSE trimRight(path(path), '/') END" ;
94101
95102 if ( filter . op === 'like' ) {
@@ -116,7 +123,7 @@ export class SimpleQueryBuilder {
116123 }
117124
118125 // Special handling for referrer filters - apply same normalization as used in queries
119- if ( filter . field === 'referrer' ) {
126+ if ( filter . field === SPECIAL_FILTER_FIELDS . REFERRER ) {
120127 const normalizedReferrerExpression =
121128 'CASE ' +
122129 "WHEN domain(referrer) LIKE '%.google.com%' OR domain(referrer) LIKE 'google.com%' THEN 'https://google.com' " +
@@ -150,7 +157,7 @@ export class SimpleQueryBuilder {
150157 }
151158
152159 // Special handling for device_type filters - convert to screen_resolution filters
153- if ( filter . field === 'device_type' && typeof filter . value === 'string' ) {
160+ if ( filter . field === SPECIAL_FILTER_FIELDS . DEVICE_TYPE && typeof filter . value === 'string' ) {
154161 const deviceType = filter . value as DeviceType ;
155162 const condition = this . getDeviceTypeFilterCondition ( deviceType ) ;
156163
@@ -301,12 +308,40 @@ export class SimpleQueryBuilder {
301308
302309 private buildGroupByClause ( ) : string {
303310 const groupBy = this . request . groupBy || this . config . groupBy ;
304- return groupBy ?. length ? ` GROUP BY ${ groupBy . join ( ', ' ) } ` : '' ;
311+ if ( ! groupBy ?. length ) {
312+ return '' ;
313+ }
314+
315+ // Security validation - only block dangerous SQL keywords
316+ const dangerousKeywords = [ 'DROP' , 'DELETE' , 'INSERT' , 'UPDATE' , 'CREATE' , 'ALTER' , 'TRUNCATE' , 'EXEC' , 'EXECUTE' ] ;
317+ for ( const field of groupBy ) {
318+ const upperField = field . toUpperCase ( ) ;
319+ for ( const keyword of dangerousKeywords ) {
320+ if ( upperField . includes ( keyword ) ) {
321+ throw new Error ( `Grouping by field '${ field } ' contains dangerous keyword: ${ keyword } ` ) ;
322+ }
323+ }
324+ }
325+
326+ return ` GROUP BY ${ groupBy . join ( ', ' ) } ` ;
305327 }
306328
307329 private buildOrderByClause ( ) : string {
308330 const orderBy = this . request . orderBy || this . config . orderBy ;
309- return orderBy ? ` ORDER BY ${ orderBy } ` : '' ;
331+ if ( ! orderBy ) {
332+ return '' ;
333+ }
334+
335+ // Security validation - only block dangerous SQL keywords
336+ const dangerousKeywords = [ 'DROP' , 'DELETE' , 'INSERT' , 'UPDATE' , 'CREATE' , 'ALTER' , 'TRUNCATE' , 'EXEC' , 'EXECUTE' ] ;
337+ const upperOrderBy = orderBy . toUpperCase ( ) ;
338+ for ( const keyword of dangerousKeywords ) {
339+ if ( upperOrderBy . includes ( keyword ) ) {
340+ throw new Error ( `Ordering by field '${ orderBy } ' contains dangerous keyword: ${ keyword } ` ) ;
341+ }
342+ }
343+
344+ return ` ORDER BY ${ orderBy } ` ;
310345 }
311346
312347 private buildLimitClause ( ) : string {
0 commit comments