From 82dfd5c3340c38da931e71e9df697f0cfbaefeb9 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 15 Jul 2026 11:05:46 -0700 Subject: [PATCH] more fixes --- .../analytics/tables/search-bar-logic.test.ts | 57 +++++++++++++++++++ .../analytics/tables/search-bar-logic.ts | 48 ++++++++++++---- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts index 41d0fe5a7..f73f439d4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.test.ts @@ -109,6 +109,63 @@ describe("getValidatedTableFilterQuery", () => { ).not.toBeNull(); }); + it("allows format() calls while rejecting a top-level FORMAT clause", () => { + expect( + getValidatedTableFilterQuery( + "SELECT * FROM users WHERE format('{}', primary_email) = 'alice@example.com'", + "users", + ), + ).not.toBeNull(); + expect( + getValidatedTableFilterQuery( + "SELECT * FROM users WHERE format ('{}', primary_email) = 'alice@example.com'", + "users", + ), + ).not.toBeNull(); + expect( + getValidatedTableFilterQuery( + "SELECT * FROM users WHERE primary_email != '' FORMAT JSON", + "users", + ), + ).toBeNull(); + }); + + it("rejects top-level set operations", () => { + expect( + getValidatedTableFilterQuery( + "SELECT * FROM users WHERE 1 UNION SELECT * FROM users", + "users", + ), + ).toBeNull(); + expect( + getValidatedTableFilterQuery( + "SELECT * FROM users WHERE 1 EXCEPT SELECT * FROM users", + "users", + ), + ).toBeNull(); + expect( + getValidatedTableFilterQuery( + "SELECT * FROM users WHERE 1 INTERSECT SELECT * FROM users", + "users", + ), + ).toBeNull(); + }); + + it("rejects internal semicolons without rejecting semicolons in strings", () => { + expect( + getValidatedTableFilterQuery( + "SELECT * FROM users WHERE 1; DROP TABLE users", + "users", + ), + ).toBeNull(); + expect( + getValidatedTableFilterQuery( + "SELECT * FROM users WHERE primary_email = 'alice;bob@example.com'", + "users", + ), + ).not.toBeNull(); + }); + it("rejects queries on a different table", () => { expect(getValidatedTableFilterQuery("SELECT * FROM events", "users")).toBe(null); // prefix of another table name must not match diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts index 8ffb6c63d..cf7b99e56 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/search-bar-logic.ts @@ -75,8 +75,10 @@ function escapeRegExp(value: string): string { } const FORBIDDEN_TOP_LEVEL_FILTER_WORDS = new Set([ + "except", "format", "having", + "intersect", "join", "limit", "offset", @@ -101,15 +103,27 @@ const FORBIDDEN_TOP_LEVEL_FILTER_PAIRS = new Set([ * filter contract only needs to distinguish clauses owned by the outer grid * from equivalent clauses inside permitted subqueries. */ -function getTopLevelSqlWords(sql: string): string[] | null { - const words: string[] = []; +type TopLevelSqlWord = { + value: string, + followedByOpenParen: boolean, +}; + +function getTopLevelSqlWords(sql: string): TopLevelSqlWord[] | null { + const words: TopLevelSqlWord[] = []; let currentWord = ""; let depth = 0; let quote: "'" | "\"" | "`" | null = null; - const flushWord = () => { + const flushWord = (nextCharacterIndex: number) => { if (currentWord.length === 0) return; - words.push(currentWord.toLowerCase()); + let nextNonWhitespaceIndex = nextCharacterIndex; + while (/\s/.test(sql[nextNonWhitespaceIndex] ?? "")) { + nextNonWhitespaceIndex += 1; + } + words.push({ + value: currentWord.toLowerCase(), + followedByOpenParen: sql[nextNonWhitespaceIndex] === "(", + }); currentWord = ""; }; @@ -132,7 +146,7 @@ function getTopLevelSqlWords(sql: string): string[] | null { } if (char === "'" || char === "\"" || char === "`") { - flushWord(); + flushWord(i); quote = char; continue; } @@ -142,6 +156,7 @@ function getTopLevelSqlWords(sql: string): string[] | null { // small scanner, so reject them instead of trying to interpret them. if ( char === "#" + || char === ";" || (char === "-" && sql[i + 1] === "-") || (char === "/" && sql[i + 1] === "*") ) { @@ -149,12 +164,12 @@ function getTopLevelSqlWords(sql: string): string[] | null { } if (char === "(") { - flushWord(); + flushWord(i); depth += 1; continue; } if (char === ")") { - flushWord(); + flushWord(i); depth -= 1; if (depth < 0) return null; continue; @@ -163,11 +178,11 @@ function getTopLevelSqlWords(sql: string): string[] | null { if (depth === 0 && /[a-z_]/i.test(char)) { currentWord += char; } else { - flushWord(); + flushWord(i); } } - flushWord(); + flushWord(sql.length); return depth === 0 && quote == null ? words : null; } @@ -177,10 +192,21 @@ function hasOnlyTopLevelRowFilterClause(filterClause: string): boolean { for (let i = 1; i < words.length; i++) { const word = words[i]!; - if (FORBIDDEN_TOP_LEVEL_FILTER_WORDS.has(word)) return false; + const isAllowedFormatFunction = + word.value === "format" && word.followedByOpenParen; + if ( + FORBIDDEN_TOP_LEVEL_FILTER_WORDS.has(word.value) + && !isAllowedFormatFunction + ) { + return false; + } } for (let i = 1; i < words.length - 1; i++) { - if (FORBIDDEN_TOP_LEVEL_FILTER_PAIRS.has(`${words[i]!} ${words[i + 1]!}`)) { + if ( + FORBIDDEN_TOP_LEVEL_FILTER_PAIRS.has( + `${words[i]!.value} ${words[i + 1]!.value}`, + ) + ) { return false; } }