Skip to content

Commit fe4d9ac

Browse files
lindesvardclaude
andcommitted
Escape timeframe and project id values in ClickHouse queries
The cohort query builder escapes every value it puts into SQL except the two absolute-timeframe dates, which were interpolated raw into toDate('...'). A date carrying a quote produced a query that no longer parsed the way the builder intended. Route both through sqlstring.escape like the rest of the file, and constrain the timeframe schema to the YYYY-MM-DD shape toDate actually expects, so a bad value is rejected at the edge instead of reaching the query. getActiveVisitorCount had the same raw interpolation for project_id. Its callers all pass an id that came back from the database, so this changes no behaviour, but it makes the query consistent with the others. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f72310c commit fe4d9ac

7 files changed

Lines changed: 161 additions & 6 deletions

File tree

packages/db/src/buffers/event-buffer.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,20 @@ describe('EventBuffer', () => {
254254
querySpy.mockRestore();
255255
});
256256

257+
it('escapes the project id in the active visitor query', async () => {
258+
const querySpy = vi
259+
.spyOn(chClient, 'chQuery')
260+
.mockResolvedValueOnce([{ count: 0 }] as any);
261+
262+
await eventBuffer.getActiveVisitorCount("p9' OR 1=1 --");
263+
264+
const sql = querySpy.mock.calls[0]![0];
265+
expect(sql).toContain("project_id = 'p9\\' OR 1=1 --'");
266+
expect(sql).not.toContain("project_id = 'p9' OR");
267+
268+
querySpy.mockRestore();
269+
});
270+
257271
it('handles multiple sessions independently — all events go to buffer', async () => {
258272
const t0 = Date.now();
259273
const count1 = await eventBuffer.getBufferSize();

packages/db/src/buffers/event-buffer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getRedisCache, publishEvent } from '@openpanel/redis';
2+
import sqlstring from 'sqlstring';
23
import { ch, chQuery } from '../clickhouse/client';
34
import type { IClickhouseEvent } from '../services/event.service';
45
import { BaseBuffer } from './base-buffer';
@@ -255,7 +256,7 @@ export class EventBuffer extends BaseBuffer {
255256
const rows = await chQuery<{ count: number }>(
256257
`SELECT uniq(profile_id) AS count
257258
FROM events
258-
WHERE project_id = '${projectId}'
259+
WHERE project_id = ${sqlstring.escape(projectId)}
259260
AND profile_id != ''
260261
AND created_at >= now() - INTERVAL 5 MINUTE`
261262
);
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { EventCriteria } from '@openpanel/validation';
2+
import { describe, expect, it } from 'vitest';
3+
import { buildEventCriteriaQuery } from './cohort.service';
4+
5+
const PROJECT_ID = 'test-cohort-timeframe';
6+
7+
function criteria(timeframe: EventCriteria['timeframe']): EventCriteria {
8+
return {
9+
name: 'screen_view',
10+
filters: [],
11+
timeframe,
12+
} as EventCriteria;
13+
}
14+
15+
/**
16+
* Remove every quoted string literal from the SQL, leaving the skeleton the
17+
* ClickHouse parser would act on. Anything a caller smuggled in stays inside a
18+
* literal if it was escaped properly, so it disappears here; if it broke out,
19+
* it shows up in the skeleton.
20+
*/
21+
function skeleton(sql: string): string {
22+
return sql.replace(/'(?:\\.|[^'\\])*'/g, "''");
23+
}
24+
25+
// Values that break out of a naive `toDate('${value}')` interpolation.
26+
const HOSTILE = [
27+
"2024-01-01') OR 1=1 --",
28+
"2024-01-01'",
29+
"2024-01-01') UNION ALL SELECT id FROM profiles --",
30+
];
31+
32+
describe('buildEventCriteriaQuery timeframe escaping', () => {
33+
it.each(HOSTILE)('escapes a hostile start (%s)', (start) => {
34+
const sql = buildEventCriteriaQuery(
35+
PROJECT_ID,
36+
criteria({ type: 'absolute', start })
37+
);
38+
39+
// The value survives as exactly one quoted literal inside toDate(...).
40+
expect(sql).toContain(
41+
`event_date >= toDate('${start.replace(/'/g, "\\'")}')`
42+
);
43+
// The parser sees the same shape as a well-formed date: no extra clause,
44+
// no early close of the toDate() call, no trailing comment.
45+
expect(skeleton(sql)).toContain("event_date >= toDate('')");
46+
expect(skeleton(sql)).not.toMatch(/OR|UNION|--/);
47+
});
48+
49+
it.each(HOSTILE)('escapes a hostile end (%s)', (end) => {
50+
const sql = buildEventCriteriaQuery(
51+
PROJECT_ID,
52+
criteria({ type: 'absolute', start: '2024-01-01', end })
53+
);
54+
55+
expect(sql).toContain(
56+
`event_date BETWEEN toDate('2024-01-01') AND toDate('${end.replace(/'/g, "\\'")}')`
57+
);
58+
expect(skeleton(sql)).toContain(
59+
"event_date BETWEEN toDate('') AND toDate('')"
60+
);
61+
expect(skeleton(sql)).not.toMatch(/OR|UNION|--/);
62+
});
63+
64+
it('leaves well-formed dates readable', () => {
65+
expect(
66+
buildEventCriteriaQuery(
67+
PROJECT_ID,
68+
criteria({ type: 'absolute', start: '2024-01-01' })
69+
)
70+
).toContain("event_date >= toDate('2024-01-01')");
71+
72+
expect(
73+
buildEventCriteriaQuery(
74+
PROJECT_ID,
75+
criteria({ type: 'absolute', start: '2024-01-01', end: '2024-02-01' })
76+
)
77+
).toContain(
78+
"event_date BETWEEN toDate('2024-01-01') AND toDate('2024-02-01')"
79+
);
80+
});
81+
82+
it('still builds relative timeframes', () => {
83+
expect(
84+
buildEventCriteriaQuery(
85+
PROJECT_ID,
86+
criteria({ type: 'relative', value: '30d' })
87+
)
88+
).toContain('event_date >= toDate(now() - INTERVAL 30 DAY)');
89+
});
90+
});

packages/db/src/services/cohort.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ function buildTimeConstraint(timeframe: Timeframe): string {
3636
return `created_at >= toDate(now() - INTERVAL ${days} DAY)`;
3737
}
3838

39-
const start = timeframe.start;
39+
const start = sqlstring.escape(timeframe.start);
4040
if (timeframe.end) {
41-
return `created_at BETWEEN toDate('${start}') AND toDate('${timeframe.end}')`;
41+
return `created_at BETWEEN toDate(${start}) AND toDate(${sqlstring.escape(timeframe.end)})`;
4242
}
43-
return `created_at >= toDate('${start}')`;
43+
return `created_at >= toDate(${start})`;
4444
}
4545

4646
function getFrequencyOperator(frequency: Frequency): string {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { zAbsoluteTimeframe } from './cohort.validation';
3+
4+
describe('zAbsoluteTimeframe', () => {
5+
it('accepts a date without an end', () => {
6+
expect(
7+
zAbsoluteTimeframe.safeParse({ type: 'absolute', start: '2024-01-01' })
8+
.success
9+
).toBe(true);
10+
});
11+
12+
it('accepts a date range', () => {
13+
expect(
14+
zAbsoluteTimeframe.safeParse({
15+
type: 'absolute',
16+
start: '2024-01-01',
17+
end: '2024-02-01',
18+
}).success
19+
).toBe(true);
20+
});
21+
22+
it.each([
23+
"2024-01-01') OR 1=1 --",
24+
'2024-1-1',
25+
'yesterday',
26+
'',
27+
])('rejects a non-date start (%s)', (start) => {
28+
expect(
29+
zAbsoluteTimeframe.safeParse({ type: 'absolute', start }).success
30+
).toBe(false);
31+
});
32+
33+
it('rejects a non-date end', () => {
34+
expect(
35+
zAbsoluteTimeframe.safeParse({
36+
type: 'absolute',
37+
start: '2024-01-01',
38+
end: "2024-01-01') --",
39+
}).success
40+
).toBe(false);
41+
});
42+
});

packages/validation/src/cohort.validation.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,15 @@ export const zRelativeTimeframe = z.object({
2929
value: z.enum(['7d', '30d', '90d', '180d', '365d']),
3030
});
3131

32+
// toDate() in the cohort query builder only understands plain calendar dates.
33+
const zDate = z
34+
.string()
35+
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected a date in YYYY-MM-DD format');
36+
3237
export const zAbsoluteTimeframe = z.object({
3338
type: z.literal('absolute'),
34-
start: z.string(),
35-
end: z.string().optional(),
39+
start: zDate,
40+
end: zDate.optional(),
3641
});
3742

3843
export const zTimeframe = z.discriminatedUnion('type', [
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { getSharedVitestConfig } from '../../vitest.shared';
2+
3+
export default getSharedVitestConfig({ __dirname });

0 commit comments

Comments
 (0)