Skip to content

Commit 66d75ec

Browse files
committed
more cleanup of SQL
1 parent 5016750 commit 66d75ec

File tree

1 file changed

+49
-14
lines changed

1 file changed

+49
-14
lines changed

apps/api/src/query/simple-builder.ts

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ import { FilterOperators } from './types';
99
import { applyPlugins } from './utils';
1010
import { 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+
1219
export 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

Comments
 (0)