Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions packages/server-utils/src/utils/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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}.
Expand All @@ -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('(?<!\\$)-?\\b\\d+\\b', 'g');
integerLiteralRE = new RegExp('(?<![$?])-?\\b\\d+\\b', 'g');
}

return (
Expand Down
192 changes: 183 additions & 9 deletions packages/server-utils/test/utils/sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,18 +615,180 @@ describe('sanitizeSqlQuery', () => {
});
});

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);
});
Expand All @@ -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 });
Expand Down
Loading