Skip to content

Commit 89bbe1c

Browse files
fix(profiles): escape search (SQLi), guard bare filter names, robust date parse
Review fixes on the profiles behavioural filter: - SECURITY (critical): escape `search` in the fuzzy ILIKE clause (list + count) with sqlstring.escape — was interpolated raw, allowing SQL injection. - SECURITY (major): reject non-identifier bare event-filter names in eventFilterClauses before interpolating (column names can't be string-escaped). - search + behavioural now applies the exact-window clamp + count threshold via buildBehavioralV2Subquery, so results match the count (was silently dropped). - last-seen-picker: parse persisted yyyy-MM-dd HH:mm:ss with date-fns instead of new Date() (Safari returned Invalid Date, leaving the picker empty). - drop the unrelated CLICKHOUSE_MAX_EXECUTION_TIME cap (separate concern).
1 parent 82c83d4 commit 89bbe1c

5 files changed

Lines changed: 43 additions & 37 deletions

File tree

.env.example

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,6 @@ ENABLE_TRPC_CACHE=""
5252
# 10 ("Too many simultaneous queries"). Unset = leave CH's default untouched.
5353
CLICKHOUSE_QUERY_LIMIT=""
5454

55-
# Hard default read timeout (seconds) for every SELECT through the app's CH
56-
# client. Without it, a read with no per-query max_execution_time runs
57-
# UNBOUNDED — a Profiles "did event X" behavioural filter once ran 45 minutes
58-
# from a laptop pointed at prod CH, spiking cluster CPU. Default 60. A per-query
59-
# max_execution_time still wins for legit long reads; prod CLICKHOUSE_SETTINGS
60-
# (spread last) overrides this. Inserts use their own 300s in the insert proxy.
61-
CLICKHOUSE_MAX_EXECUTION_TIME="60"
62-
6355
# PROFILES PAGE — BEHAVIORAL FILTER v2 ROUTING
6456
# When the profiles-page behavioural filter ("users who did event X where
6557
# properties.k=v in last N days") runs, route the subquery to

apps/start/src/components/profiles/last-seen-picker.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ interface Props {
2424

2525
const DATE_FMT = 'MMM d, yyyy';
2626
const DATETIME_FMT = 'MMM d, yyyy, hh:mm a';
27+
const DB_FMT = 'yyyy-MM-dd HH:mm:ss';
28+
29+
// Parse a persisted DB-format string with date-fns (not `new Date(...)`, which
30+
// is browser-dependent for space-separated `yyyy-MM-dd HH:mm:ss` — Safari yields
31+
// Invalid Date). Returns undefined for null/invalid so the picker stays empty
32+
// rather than showing NaN.
33+
function parseDb(s: string | null): Date | undefined {
34+
if (!s) return undefined;
35+
const d = parse(s, DB_FMT, new Date());
36+
return isValid(d) ? d : undefined;
37+
}
2738

2839
// Copy a calendar-picked day onto an existing datetime, preserving the time
2940
// (react-day-picker returns midnight). Falls back to a default HH:mm.
@@ -56,18 +67,14 @@ export function LastSeenPicker({
5667
const [mode, setMode] = useState<Mode>(
5768
startDate && !endDate ? 'since' : 'fixed',
5869
);
59-
const [from, setFrom] = useState<Date | undefined>(
60-
startDate ? new Date(startDate) : undefined,
61-
);
62-
const [to, setTo] = useState<Date | undefined>(
63-
endDate ? new Date(endDate) : undefined,
64-
);
70+
const [from, setFrom] = useState<Date | undefined>(parseDb(startDate));
71+
const [to, setTo] = useState<Date | undefined>(parseDb(endDate));
6572
const [enableTime, setEnableTime] = useState(false);
6673

6774
const sync = () => {
6875
setMode(startDate && !endDate ? 'since' : 'fixed');
69-
setFrom(startDate ? new Date(startDate) : undefined);
70-
setTo(endDate ? new Date(endDate) : undefined);
76+
setFrom(parseDb(startDate));
77+
setTo(parseDb(endDate));
7178
};
7279

7380
const canApply = mode === 'fixed' ? !!from && !!to : !!from;

docs/profiles-v2-behavioral-filter.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,12 @@ count above the identified-only base.
131131

132132
- `PROFILES_BEHAVIORAL_V2_PROJECTS` — comma-separated allowlist (per-project rollout).
133133
- `PROFILES_BEHAVIORAL_V2_START_DATE` — earliest window start v2 may serve.
134-
- `CLICKHOUSE_MAX_EXECUTION_TIME` — app CH client cap (default 60 s) so a stray
135-
read can't run for minutes. Prod's `CLICKHOUSE_SETTINGS` (40 s) still wins.
136134

137135
## Files
138136

139137
- `packages/db/src/services/profile.service.ts` — routing, two-step, clamp,
140138
operators, count.
141-
- `packages/db/src/clickhouse/client.ts` — v2 in `TABLE_NAMES`; `max_execution_time`.
139+
- `packages/db/src/clickhouse/client.ts` — v2 in `TABLE_NAMES`.
142140
- `packages/trpc/src/routers/profile.ts``eventCount` input.
143141
- `apps/start/.../profiles._tabs.identified.tsx` — query wiring, 15-day default,
144142
all-profiles (no is_external).

packages/db/src/clickhouse/client.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -111,20 +111,8 @@ function getClickhouseSettings(): ClickHouseSettings {
111111
? rawQueryLimit
112112
: undefined;
113113

114-
// Hard default read timeout. Without this, a read query with no per-query
115-
// max_execution_time runs UNBOUNDED — a Profiles "did event X" behavioral
116-
// filter (id IN (SELECT … FROM events …) + FINAL) ran 45 min from a laptop
117-
// against prod CH (public IP, avnadmin), peaking 3.5 GB and spiking cluster
118-
// CPU, because nothing capped it. This floor caps every read via this client.
119-
// Inserts override to 300 in the `insert` proxy; a per-query max_execution_time
120-
// still wins for legitimately long reads; prod CLICKHOUSE_SETTINGS can override.
121-
const rawMaxExec = process.env.CLICKHOUSE_MAX_EXECUTION_TIME?.trim();
122-
const maxExecutionTime =
123-
rawMaxExec && Number.isFinite(Number(rawMaxExec)) ? Number(rawMaxExec) : 60;
124-
125114
return {
126115
date_time_input_format: 'best_effort',
127-
max_execution_time: maxExecutionTime,
128116
...(queryLimit ? { max_concurrent_queries_for_user: queryLimit } : {}),
129117
...(!process.env.CLICKHOUSE_SETTINGS_REMOVE_CONVERT_ANY_JOIN
130118
? {

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

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ function buildBehavioralV2Subquery(
393393
startDate: string | null | undefined,
394394
endDate: string | null | undefined,
395395
tz: string,
396+
eventCount: IProfileEventCount | undefined,
396397
): string {
397398
const where = buildBehavioralV2WhereClause(
398399
projectId,
@@ -403,6 +404,14 @@ function buildBehavioralV2Subquery(
403404
endDate,
404405
tz,
405406
);
407+
// Apply the same exact-window clamp + count threshold as the two-step path, so
408+
// the search+behavioral list (which routes through here, not the two-step)
409+
// matches the count and honours "OP N times". Without a HAVING, a plain
410+
// DISTINCT is enough and cheaper.
411+
const having = buildBehavioralV2Having(startDate, endDate, tz, eventCount);
412+
if (having) {
413+
return `SELECT profile_id FROM ${TABLE_NAMES.profile_event_property_summary_v2} WHERE ${where} GROUP BY profile_id ${having}`;
414+
}
406415
return `SELECT DISTINCT profile_id FROM ${TABLE_NAMES.profile_event_property_summary_v2} WHERE ${where}`;
407416
}
408417

@@ -539,9 +548,16 @@ function eventFilterClauses(filters: IChartEventFilter[]): string[] {
539548
const out: string[] = [];
540549
for (const f of filters) {
541550
if (!f.name || !f.value?.length) continue;
542-
const col = f.name.startsWith('properties.')
543-
? `properties[${sqlstring.escape(f.name.replace(/^properties\./, ''))}]`
544-
: f.name;
551+
let col: string;
552+
if (f.name.startsWith('properties.')) {
553+
col = `properties[${sqlstring.escape(f.name.replace(/^properties\./, ''))}]`;
554+
} else {
555+
// Bare event column (country/os/path/…). It's a SQL identifier, not a
556+
// string literal, so it can't be sqlstring.escape'd — reject anything that
557+
// isn't a plain identifier to block injection via a crafted filter name.
558+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(f.name)) continue;
559+
col = f.name;
560+
}
545561
const inList = f.value.map((v) => sqlstring.escape(String(v))).join(',');
546562
if (f.operator === 'isNot') {
547563
out.push(`${col} NOT IN (${inList})`);
@@ -702,7 +718,10 @@ export async function getProfileList({
702718
} else if (opts.searchMode === 'fuzzy') {
703719
// Substring name/email search. ILIKE '%x%' can't use any index (the bloom
704720
// filters only help exact/token matches), so it scans — inherently slow.
705-
sb.where.search = `(email ILIKE '%${search}%' OR first_name ILIKE '%${search}%' OR last_name ILIKE '%${search}%')`;
721+
// Escape the user input: sqlstring.escape wraps + escapes so a crafted
722+
// `search` can't break out of the string literal (SQL injection).
723+
const like = sqlstring.escape(`%${search}%`);
724+
sb.where.search = `(email ILIKE ${like} OR first_name ILIKE ${like} OR last_name ILIKE ${like})`;
706725
}
707726
if (isExternal !== undefined) {
708727
sb.where.external = `is_external = ${isExternal ? 'true' : 'false'}`;
@@ -718,7 +737,7 @@ export async function getProfileList({
718737
// either way, so "Last seen" (profiles.created_at) and sorting are
719738
// unchanged. Falls back to events for anything v2 can't serve.
720739
if (canRouteBehavioralToV2(projectId, filters ?? [], range, startDate)) {
721-
sb.where.behavioral = `id IN (${buildBehavioralV2Subquery(projectId, eventNames, filters ?? [], range, startDate, endDate, tz)})`;
740+
sb.where.behavioral = `id IN (${buildBehavioralV2Subquery(projectId, eventNames, filters ?? [], range, startDate, endDate, tz, eventCount)})`;
722741
} else {
723742
const names = eventNames.map((e) => sqlstring.escape(e)).join(',');
724743
const parts = [
@@ -924,7 +943,9 @@ export async function getProfileListCount({
924943
sb.where.project_id = `project_id = ${sqlstring.escape(projectId)}`;
925944
sb.groupBy.project_id = 'project_id';
926945
if (search) {
927-
sb.where.search = `(email ILIKE '%${search}%' OR first_name ILIKE '%${search}%' OR last_name ILIKE '%${search}%')`;
946+
// Escape user input — see getProfileList fuzzy branch (SQL injection).
947+
const like = sqlstring.escape(`%${search}%`);
948+
sb.where.search = `(email ILIKE ${like} OR first_name ILIKE ${like} OR last_name ILIKE ${like})`;
928949
}
929950
if (isExternal !== undefined) {
930951
sb.where.external = `is_external = ${isExternal ? 'true' : 'false'}`;

0 commit comments

Comments
 (0)