diff --git a/packages/server-utils/src/utils/sql.ts b/packages/server-utils/src/utils/sql.ts index 2ae8ef4450ad..a015f895ca0a 100644 --- a/packages/server-utils/src/utils/sql.ts +++ b/packages/server-utils/src/utils/sql.ts @@ -152,13 +152,17 @@ let integerLiteralRE: RegExp | undefined; /** * SQL dialect variants that matter for finding the end of a string literal: - * - `standard` (PostgreSQL, SQLite): `"` quotes identifiers and `''` is the only in-string escape. + * - `standard` (PostgreSQL, SQLite, SQL Server): `"` quotes identifiers, `''` is the only + * in-string escape, and PostgreSQL's `$$…$$` dollar quoting opens a literal. * - `mysql`: `"` quotes a string literal unless `ANSI_QUOTES` is set, and `\` escapes the next * character unless `NO_BACKSLASH_ESCAPES` is set. Both default to off, and mysql/mysql2 escape * inlined values with backslashes, so this is the mode their statements arrive in. */ export type SqlDialect = 'standard' | 'mysql'; +// Sticky, so the scanner can test one position without slicing the query on every `$`. +const DOLLAR_QUOTE_RE = /\$(?:[A-Za-z_]\w*)?\$/y; + /** * Returns the index just past the run's closing `delimiter`, or the end of the query if the run is * never closed — an unterminated literal must swallow the remainder rather than let it through. @@ -213,6 +217,19 @@ function stripLiteralsAndComments(sql: string, dialect: SqlDialect): string { continue; } + // In a dollar-quoted body (`$$body$$`, `$tag$body$tag$`) nothing has syntax meaning. Read the + // previous character from the query, not from `out`, where a dropped comment would leave `$$` + // looking like part of an identifier. MySQL has no dollar quoting and allows `$` in names. + if (!isMysql && char === '$' && !isIdentifierChar(sql[i - 1])) { + const tag = matchDollarQuoteTag(sql, i); + if (tag) { + const bodyEnd = sql.indexOf(tag, i + tag.length); + i = bodyEnd === -1 ? sql.length : bodyEnd + tag.length; + out += '?'; + continue; + } + } + // Quoted identifiers: backticks in MySQL, double quotes everywhere else if (char === '`' || (char === '"' && !isMysql)) { const runEnd = findQuotedRunEnd(sql, i, char, false); @@ -222,7 +239,7 @@ function stripLiteralsAndComments(sql: string, dialect: SqlDialect): string { } if (char === "'" || (char === '"' && isMysql)) { - // A prefix like `X'1A'`, `B'01'` or PostgreSQL's `E'a\nb'` is part of the literal, so it has + // A prefix like `X'1A'`, `B'01'`, `N'…'` or PostgreSQL's `E'a\nb'` is part of the literal, so it has // to collapse into the same `?` instead of being left behind as a bare identifier. const prefix = char === "'" ? getLiteralPrefix(out, isMysql) : undefined; out = prefix ? out.slice(0, -1) : out; @@ -238,18 +255,30 @@ function stripLiteralsAndComments(sql: string, dialect: SqlDialect): string { return out; } +/** Whether `char` can appear inside an identifier. `undefined` (start of query) counts as a break. */ +function isIdentifierChar(char: string | undefined): boolean { + return char !== undefined && /[\w$]/.test(char); +} + +/** Returns the opening dollar-quote tag at `start` (`$$` or `$tag$`), or undefined if there is none. */ +function matchDollarQuoteTag(sql: string, start: number): string | undefined { + DOLLAR_QUOTE_RE.lastIndex = start; + return DOLLAR_QUOTE_RE.exec(sql)?.[0]; +} + /** * Returns the literal-prefix character immediately before a `'`, if there is one: `X`/`B` for - * hex/binary literals, or `E` for a PostgreSQL escape string (which honors backslash escapes). + * hex/binary literals, `N` for a national-character literal (SQL Server, MySQL), or `E` for a + * PostgreSQL escape string (which honors backslash escapes). */ -function getLiteralPrefix(out: string, isMysql: boolean): 'X' | 'B' | 'E' | undefined { +function getLiteralPrefix(out: string, isMysql: boolean): 'X' | 'B' | 'N' | 'E' | undefined { // A prefix only counts when it stands alone — the `X` in `MAX'...'` belongs to the identifier - if (/[\w$]/.test(out.slice(-2, -1))) { + if (isIdentifierChar(out.slice(-2, -1))) { return undefined; } const prefix = out.slice(-1).toUpperCase(); - if (prefix === 'X' || prefix === 'B') { + if (prefix === 'X' || prefix === 'B' || prefix === 'N') { return prefix; } return prefix === 'E' && !isMysql ? 'E' : undefined; @@ -259,8 +288,9 @@ function getLiteralPrefix(out: string, isMysql: boolean): 'X' | 'B' | 'E' | unde * Sanitize SQL query as per the OTEL semantic conventions * https://opentelemetry.io/docs/specs/semconv/database/database-spans/#sanitization-of-dbquerytext * - * PostgreSQL $n placeholders are preserved per OTEL spec - they're parameterized queries, - * not sensitive literals. Only actual values (strings, numbers, booleans) are sanitized. + * Parameter placeholders survive: PostgreSQL `$n`, SQLite `?n`, and named forms like `:name` and + * `@name`. Per the OTEL spec they mark a parameterized query, so only values (strings, numbers, + * booleans) are sanitized. * * Pass `dialect` when the statement comes from a driver whose literals are not standard-quoted; * see {@link SqlDialect}. @@ -274,7 +304,7 @@ export function sanitizeSqlQuery(sqlQuery: string | undefined, dialect: SqlDiale // on import and crash Safari <16.4 browser bundles that reach this file via // the core barrel. Building it on first call keeps the cost off the import path. if (!integerLiteralRE) { - integerLiteralRE = new RegExp('(? { }); }); + describe("dialect: 'standard'", () => { + it.each([ + // `"..."` quotes an identifier, so it survives as a summary target + ['SELECT * FROM "User" WHERE "email" = \'jane@example.com\'', 'SELECT * FROM "User" WHERE "email" = ?'], + ['SELECT "col""umn" FROM t WHERE a = 1', 'SELECT "col""umn" FROM t WHERE a = ?'], + // PostgreSQL reads `#` as a bitwise operator, not a comment + ['SELECT * FROM t WHERE a # 1 = 2', 'SELECT * FROM t WHERE a # ? = ?'], + // `\` is an ordinary character, so the literal ends at the next quote + [String.raw`SELECT * FROM t WHERE path = 'C:\' AND b = 2`, 'SELECT * FROM t WHERE path = ? AND b = ?'], + // dollar-quoted strings (PostgreSQL), tagged and untagged + ["SELECT * FROM t WHERE a = $$O'Brien$$", 'SELECT * FROM t WHERE a = ?'], + ['SELECT * FROM t WHERE a = $tag$secret$tag$ AND b = $1', 'SELECT * FROM t WHERE a = ? AND b = $1'], + ['SELECT $$a$$, $$b$$ FROM t', 'SELECT ?, ? FROM t'], + // a `$` inside an identifier does not open a dollar quote, even when a dropped comment is + // what separates the two + ['SELECT a$$b FROM t WHERE c = 1', 'SELECT a$$b FROM t WHERE c = ?'], + ['SELECT a /* c */ $$secret$$ FROM t', 'SELECT a ? FROM t'], + ['SELECT a/* c */$$secret$$ FROM t', 'SELECT a? FROM t'], + // `$name` is a SQLite parameter placeholder, not a dollar quote + ['INSERT INTO t (a, b) VALUES ($name, $email)', 'INSERT INTO t (a, b) VALUES ($name, $email)'], + // national-character literals collapse with their prefix: `N'...'` in SQL Server (which + // requires the uppercase N) and in MySQL (which takes either case) + ["SELECT * FROM t WHERE name = N'Jane'", 'SELECT * FROM t WHERE name = ?'], + ["SELECT * FROM t WHERE name = n'Jane'", 'SELECT * FROM t WHERE name = ?'], + // ... unless the `N` belongs to the identifier before it. `MIN'x'` parses in no dialect. It + // guards against a name ending in N eating the quote after it. + ["SELECT MIN'x' FROM t", 'SELECT MIN? FROM t'], + ])('sanitizes %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + expect(sanitizeSqlQuery(input, 'standard')).toBe(expected); + }); + + // Known limit: SQLite reads `"..."` as a string when the name matches no column + // (`SQLITE_DQS`), and only the schema tells that apart from an identifier. Drivers that bind + // their values never emit this shape. + it('leaves a SQLite double-quoted string in place', () => { + expect(sanitizeSqlQuery('SELECT * FROM t WHERE a = "jane@example.com"')).toBe( + 'SELECT * FROM t WHERE a = "jane@example.com"', + ); + }); + }); + + describe('dialect divergence', () => { + it.each([ + // [input, standard, mysql] + ['SELECT * FROM t WHERE name = "Jane"', 'SELECT * FROM t WHERE name = "Jane"', 'SELECT * FROM t WHERE name = ?'], + ['SELECT * FROM t WHERE a = 1 # 2', 'SELECT * FROM t WHERE a = ? # ?', 'SELECT * FROM t WHERE a = ?'], + [ + String.raw`SELECT * FROM t WHERE a = 'x\' AND b = 'Jane'`, + 'SELECT * FROM t WHERE a = ? AND b = ?', + // MySQL reads `\'` as an escaped quote, so the literal runs to the quote before `Jane` and + // swallows the statement text. The server lexes it the same way, which leaves `Jane` a bare + // token here, not the value it looks like. + 'SELECT * FROM t WHERE a = ?Jane?', + ], + // MySQL has no dollar quoting and allows `$` in identifiers, so it leaves `$...$` alone + ['SELECT $col$x FROM t WHERE a = 1', 'SELECT ?', 'SELECT $col$x FROM t WHERE a = ?'], + ['SELECT `a` FROM t WHERE b = 1', 'SELECT `a` FROM t WHERE b = ?', 'SELECT `a` FROM t WHERE b = ?'], + ])('%p sanitizes to %p (standard) and %p (mysql)', (input, standard, mysql) => { + expect(sanitizeSqlQuery(input, 'standard')).toBe(standard); + expect(sanitizeSqlQuery(input, 'mysql')).toBe(mysql); + }); + }); + + describe('unterminated literals swallow the rest of the statement', () => { + it.each([ + ["SELECT * FROM t WHERE a = 'jane@example.com AND b = 2", 'standard' as const], + ["SELECT * FROM t WHERE a = N'jane@example.com AND b = 2", 'standard' as const], + ['SELECT * FROM t WHERE a = $$jane@example.com AND b = 2', 'standard' as const], + ['SELECT * FROM t WHERE a = "jane@example.com AND b = 2', 'mysql' as const], + ["SELECT * FROM t WHERE a = 'jane@example.com AND b = 2", 'mysql' as const], + ])('drops the unterminated value in %p (%s)', (input, dialect) => { + expect(sanitizeSqlQuery(input, dialect)).toBe('SELECT * FROM t WHERE a = ?'); + }); + }); + + describe('representative statements per driver', () => { + it.each([ + // pg / postgres-js: parameterized text passes through unchanged + [ + 'SELECT "User"."id" FROM "public"."User" WHERE "User"."email" = $1 AND "User"."age" > $2 LIMIT $3', + 'SELECT "User"."id" FROM "public"."User" WHERE "User"."email" = $1 AND "User"."age" > $2 LIMIT $3', + ], + // pg: values inlined by the caller instead of bound + [ + "SELECT * FROM users WHERE email = 'jane@example.com' AND created_at > '2024-01-01' ORDER BY id DESC LIMIT 10", + 'SELECT * FROM users WHERE email = ? AND created_at > ? ORDER BY id DESC LIMIT ?', + ], + [ + "UPDATE accounts SET balance = balance - 42.50, note = 'rent for jane' WHERE owner_email = 'jane@example.com'", + 'UPDATE accounts SET balance = balance - ?, note = ? WHERE owner_email = ?', + ], + [ + "DELETE FROM sessions WHERE token = 'sk_live_abc123' OR expires_at < NOW() - INTERVAL '7 days'", + 'DELETE FROM sessions WHERE token = ? OR expires_at < NOW() - INTERVAL ?', + ], + ])('sanitizes PostgreSQL statement %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + + it.each([ + // mysql/mysql2 escape inlined values with backslashes and quote identifiers with backticks + [ + "SELECT * FROM `users` WHERE `email` = 'o\\'brien@example.com' AND `active` = 1", + 'SELECT * FROM `users` WHERE `email` = ? AND `active` = ?', + ], + [ + "INSERT INTO `users` (`name`, `bio`) VALUES ('Jane', 'says \\\"hi\\\" a lot')", + 'INSERT INTO `users` (`name`, `bio`) VALUES (?, ?)', + ], + [ + 'SELECT * FROM `users` WHERE `id` = ? AND `status` = ?', + 'SELECT * FROM `users` WHERE `id` = ? AND `status` = ?', + ], + [ + 'UPDATE `orders` SET `note` = "customer said: don\'t ship" WHERE `id` = 7', + 'UPDATE `orders` SET `note` = ? WHERE `id` = ?', + ], + ])('sanitizes MySQL statement %p', (input, expected) => { + expect(sanitizeSqlQuery(input, 'mysql')).toBe(expected); + }); + + it.each([ + // SQLite (D1, Nitro/Nuxt): `?`, `?n` and named placeholders survive, inlined values do not + [ + "INSERT INTO users (name, email) VALUES ('Jane', 'jane@example.com')", + 'INSERT INTO users (name, email) VALUES (?, ?)', + ], + [ + 'INSERT OR REPLACE INTO users (id, email) VALUES (?1, ?2)', + 'INSERT OR REPLACE INTO users (id, email) VALUES (?1, ?2)', + ], + [ + 'SELECT * FROM users WHERE email = :email AND age > @minAge', + 'SELECT * FROM users WHERE email = :email AND age > @minAge', + ], + ['PRAGMA table_info(users)', 'PRAGMA table_info(users)'], + ])('sanitizes SQLite statement %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + + it.each([ + // tedious (SQL Server): `@P1` placeholders survive, `N'...'` literals do not + [ + 'SELECT [id], [email] FROM [dbo].[users] WHERE [email] = @P1 AND [age] > @P2', + 'SELECT [id], [email] FROM [dbo].[users] WHERE [email] = @P1 AND [age] > @P2', + ], + ["SELECT TOP 10 * FROM users WHERE email = N'jane@example.com'", 'SELECT TOP ? * FROM users WHERE email = ?'], + [ + "INSERT INTO users (name, email) VALUES (N'Jane', N'jane@example.com')", + 'INSERT INTO users (name, email) VALUES (?, ?)', + ], + ])('sanitizes SQL Server statement %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); + }); + describe('regression: values must not survive as summary targets', () => { // A literal that survives sanitization and happens to contain `from`/`join`/`select` is read // as a table name by getSqlQuerySummary, which puts it in `db.query.summary` and — with span // streaming — in the span name. it.each([ - ['SELECT * FROM users WHERE name = "from bob@secret.com"', 'bob@secret.com'], - ['SELECT * FROM users WHERE bio = "i come from Berlin and join clubs"', 'Berlin'], - ['INSERT INTO t (c) VALUES ("select from s3cret-token")', 's3cret-token'], - [String.raw`SELECT * FROM users WHERE name = 'O\'Brien from ACME'`, 'ACME'], - [String.raw`UPDATE t SET a = 'x\'y from Z' WHERE id = 5`, 'from Z'], - ])('strips the value out of %p', (input, value) => { - const sanitized = sanitizeSqlQuery(input, 'mysql'); + ['mysql' as const, 'SELECT * FROM users WHERE name = "from bob@secret.com"', 'bob@secret.com'], + ['mysql' as const, 'SELECT * FROM users WHERE bio = "i come from Berlin and join clubs"', 'Berlin'], + ['mysql' as const, 'INSERT INTO t (c) VALUES ("select from s3cret-token")', 's3cret-token'], + ['mysql' as const, String.raw`SELECT * FROM users WHERE name = 'O\'Brien from ACME'`, 'ACME'], + ['mysql' as const, String.raw`UPDATE t SET a = 'x\'y from Z' WHERE id = 5`, 'from Z'], + ['standard' as const, "SELECT * FROM users WHERE name = 'from bob@secret.com'", 'bob@secret.com'], + ['standard' as const, 'SELECT * FROM users WHERE bio = $$i come from Berlin and join clubs$$', 'Berlin'], + ['standard' as const, 'INSERT INTO t (c) VALUES ($tag$select from s3cret-token$tag$)', 's3cret-token'], + ['standard' as const, "SELECT * FROM users WHERE name = N'from ACME'", 'ACME'], + ['standard' as const, String.raw`UPDATE t SET a = E'x\'y from Z' WHERE id = 5`, 'from Z'], + ])('strips the value out of %s statement %p', (dialect, input, value) => { + const sanitized = sanitizeSqlQuery(input, dialect); expect(sanitized).not.toContain(value); expect(getSqlQuerySummary(sanitized)).not.toContain(value); }); @@ -641,13 +803,25 @@ describe('sanitizeSqlQueryWithSummary', () => { }); }); - it('passes the dialect through to the sanitizer', () => { - expect(sanitizeSqlQueryWithSummary('SELECT * FROM users WHERE email = "jane@example.com"', 'mysql')).toEqual({ + it.each([ + ['mysql' as const, 'SELECT * FROM users WHERE email = "jane@example.com"'], + ['standard' as const, "SELECT * FROM users WHERE email = 'jane@example.com'"], + ['standard' as const, 'SELECT * FROM users WHERE email = $$jane@example.com$$'], + ['standard' as const, "SELECT * FROM users WHERE email = N'jane@example.com'"], + ])('passes the %s dialect through to the sanitizer: %p', (dialect, input) => { + expect(sanitizeSqlQueryWithSummary(input, dialect)).toEqual({ queryText: 'SELECT * FROM users WHERE email = ?', querySummary: 'SELECT users', }); }); + it('derives the summary from the sanitized statement, not the raw one', () => { + expect(sanitizeSqlQueryWithSummary("SELECT * FROM users WHERE bio = 'from secret_table'")).toEqual({ + queryText: 'SELECT * FROM users WHERE bio = ?', + querySummary: 'SELECT users', + }); + }); + it('returns undefined for both when there is no statement', () => { expect(sanitizeSqlQueryWithSummary(undefined)).toEqual({ queryText: undefined, querySummary: undefined }); expect(sanitizeSqlQueryWithSummary('')).toEqual({ queryText: undefined, querySummary: undefined });