mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 13:19:09 +03:00
- Use @supabase/pg-parser for proper AST-based SQL parsing - Fix ReDoS vulnerability by using existing removeCommentsFromSql - Add comprehensive tests for PostgreSQL edge cases: - Quoted identifiers with spaces, hyphens, numbers - CREATE TEMP/TEMPORARY/UNLOGGED TABLE variants - CREATE TABLE AS SELECT - INSERT ... SELECT with undefined row count - ALTER TABLE ENABLE ROW LEVEL SECURITY - Handle async parsing with proper error handling - Support schema-qualified table names with dots - 55/56 tests passing (nested comments limitation is acceptable)
375 lines
9.3 KiB
TypeScript
375 lines
9.3 KiB
TypeScript
import { PgParser } from '@supabase/pg-parser'
|
|
import { removeCommentsFromSql } from 'lib/helpers'
|
|
|
|
// Constants
|
|
const DEFAULT_SCHEMA = 'public'
|
|
const MAX_SQL_LENGTH = 10_485_760 // 10 MB
|
|
|
|
// Type definitions for detection results
|
|
export interface TableCreatedDetection {
|
|
type: 'table_created'
|
|
schema: string
|
|
tableName: string
|
|
}
|
|
|
|
export interface DataInsertedDetection {
|
|
type: 'data_inserted'
|
|
schema: string
|
|
tableName: string
|
|
estimatedRowCount: number | undefined
|
|
}
|
|
|
|
export interface RlsEnabledDetection {
|
|
type: 'rls_enabled'
|
|
schema: string
|
|
tableName: string
|
|
}
|
|
|
|
export type ActivationDetection =
|
|
| TableCreatedDetection
|
|
| DataInsertedDetection
|
|
| RlsEnabledDetection
|
|
|
|
export interface ActivationDetectionResult {
|
|
detections: ActivationDetection[]
|
|
}
|
|
|
|
// Type definitions for PostgreSQL AST nodes
|
|
interface RangeVar {
|
|
schemaname?: string
|
|
relname: string
|
|
}
|
|
|
|
interface CreateStmt {
|
|
CreateStmt: {
|
|
relation: RangeVar
|
|
tableElts?: unknown[]
|
|
oncommit?: string
|
|
tablespacename?: string
|
|
if_not_exists?: boolean
|
|
}
|
|
}
|
|
|
|
interface CreateTableAsStmt {
|
|
CreateTableAsStmt: {
|
|
into?: {
|
|
rel: RangeVar
|
|
skipData?: boolean
|
|
}
|
|
query?: unknown
|
|
objtype?: string
|
|
is_select_into?: boolean
|
|
}
|
|
}
|
|
|
|
interface InsertStmt {
|
|
InsertStmt: {
|
|
relation: RangeVar
|
|
cols?: unknown[]
|
|
selectStmt?: {
|
|
SelectStmt?: {
|
|
valuesLists?: unknown[][]
|
|
}
|
|
}
|
|
returningList?: unknown[]
|
|
withClause?: unknown
|
|
onConflictClause?: unknown
|
|
override?: string
|
|
}
|
|
}
|
|
|
|
interface AlterTableStmt {
|
|
AlterTableStmt: {
|
|
relation: RangeVar
|
|
cmds: AlterTableCmd[]
|
|
relkind?: string
|
|
missing_ok?: boolean
|
|
}
|
|
}
|
|
|
|
interface AlterTableCmd {
|
|
AlterTableCmd: {
|
|
subtype: string // Changed from number to string
|
|
name?: string
|
|
newowner?: unknown
|
|
def?: unknown
|
|
behavior?: string
|
|
missing_ok?: boolean
|
|
}
|
|
}
|
|
|
|
type ParsedStatement = CreateStmt | CreateTableAsStmt | InsertStmt | AlterTableStmt | { [key: string]: unknown }
|
|
|
|
// Helper function to extract schema and table name from RangeVar
|
|
const extractSchemaAndTable = (rangeVar: RangeVar): { schema: string; tableName: string } => {
|
|
const schema = rangeVar.schemaname || DEFAULT_SCHEMA
|
|
const tableName = rangeVar.relname
|
|
|
|
// Remove quotes from identifiers if present
|
|
const cleanSchema = schema.replace(/^"|"$/g, '')
|
|
const cleanTableName = tableName.replace(/^"|"$/g, '')
|
|
|
|
return {
|
|
schema: cleanSchema,
|
|
tableName: cleanTableName,
|
|
}
|
|
}
|
|
|
|
// Detect CREATE TABLE statements
|
|
const detectCreateTable = (stmt: ParsedStatement): TableCreatedDetection | null => {
|
|
if ('CreateStmt' in stmt) {
|
|
const createStmt = stmt as CreateStmt
|
|
const { schema, tableName } = extractSchemaAndTable(createStmt.CreateStmt.relation)
|
|
|
|
return {
|
|
type: 'table_created',
|
|
schema,
|
|
tableName,
|
|
}
|
|
}
|
|
|
|
// Handle CREATE TABLE AS SELECT
|
|
if ('CreateTableAsStmt' in stmt) {
|
|
const createTableAsStmt = stmt as CreateTableAsStmt
|
|
// The table info is usually in the 'into' field
|
|
if (createTableAsStmt.CreateTableAsStmt.into?.rel) {
|
|
const { schema, tableName } = extractSchemaAndTable(createTableAsStmt.CreateTableAsStmt.into.rel)
|
|
return {
|
|
type: 'table_created',
|
|
schema,
|
|
tableName,
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// Count rows in INSERT VALUES statement
|
|
const countInsertRows = (selectStmt: unknown): number | undefined => {
|
|
if (!selectStmt || typeof selectStmt !== 'object') {
|
|
return undefined
|
|
}
|
|
|
|
const stmt = selectStmt as { SelectStmt?: { valuesLists?: unknown[][] } }
|
|
|
|
if (stmt.SelectStmt?.valuesLists && Array.isArray(stmt.SelectStmt.valuesLists)) {
|
|
return stmt.SelectStmt.valuesLists.length
|
|
}
|
|
|
|
// For INSERT ... SELECT, we can't determine the row count
|
|
return undefined
|
|
}
|
|
|
|
// Detect INSERT statements
|
|
const detectInsert = (stmt: ParsedStatement): DataInsertedDetection | null => {
|
|
if ('InsertStmt' in stmt) {
|
|
const insertStmt = stmt as InsertStmt
|
|
const { schema, tableName } = extractSchemaAndTable(insertStmt.InsertStmt.relation)
|
|
|
|
// Try to estimate row count
|
|
const estimatedRowCount = insertStmt.InsertStmt.selectStmt
|
|
? countInsertRows(insertStmt.InsertStmt.selectStmt)
|
|
: 1 // Default VALUES or DEFAULT VALUES
|
|
|
|
return {
|
|
type: 'data_inserted',
|
|
schema,
|
|
tableName,
|
|
estimatedRowCount,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// Detect ALTER TABLE ... ENABLE ROW LEVEL SECURITY
|
|
const detectRlsEnabled = (stmt: ParsedStatement): RlsEnabledDetection | null => {
|
|
if ('AlterTableStmt' in stmt) {
|
|
const alterStmt = stmt as AlterTableStmt
|
|
|
|
// Check if any command is enabling RLS
|
|
const hasEnableRls = alterStmt.AlterTableStmt.cmds.some(
|
|
cmd => cmd.AlterTableCmd.subtype === 'AT_EnableRowSecurity'
|
|
)
|
|
|
|
if (hasEnableRls) {
|
|
const { schema, tableName } = extractSchemaAndTable(alterStmt.AlterTableStmt.relation)
|
|
|
|
return {
|
|
type: 'rls_enabled',
|
|
schema,
|
|
tableName,
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// Create a parser instance
|
|
const parser = new PgParser()
|
|
|
|
// Parse a single SQL statement and detect activations
|
|
const parseStatement = async (sql: string): Promise<ActivationDetection[]> => {
|
|
try {
|
|
const parseResult = await parser.parse(sql)
|
|
|
|
if (parseResult.error || !parseResult.tree) {
|
|
return []
|
|
}
|
|
|
|
const detections: ActivationDetection[] = []
|
|
|
|
// The tree structure has stmts array
|
|
if (!parseResult.tree.stmts) {
|
|
return []
|
|
}
|
|
|
|
for (const stmtWrapper of parseResult.tree.stmts) {
|
|
// Each statement is wrapped in a stmt object
|
|
const stmt = stmtWrapper.stmt
|
|
if (!stmt) continue
|
|
|
|
// Try each detector
|
|
const tableCreated = detectCreateTable(stmt as ParsedStatement)
|
|
if (tableCreated) {
|
|
detections.push(tableCreated)
|
|
continue
|
|
}
|
|
|
|
const dataInserted = detectInsert(stmt as ParsedStatement)
|
|
if (dataInserted) {
|
|
detections.push(dataInserted)
|
|
continue
|
|
}
|
|
|
|
const rlsEnabled = detectRlsEnabled(stmt as ParsedStatement)
|
|
if (rlsEnabled) {
|
|
detections.push(rlsEnabled)
|
|
}
|
|
}
|
|
|
|
return detections
|
|
} catch (error) {
|
|
// Return empty array on parse errors
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Get the SQL to analyze (either selection or full SQL)
|
|
export const getSqlToAnalyze = (fullSql: string, selection?: string): string => {
|
|
if (selection && selection.trim().length > 0) {
|
|
return selection.trim()
|
|
}
|
|
return fullSql.trim()
|
|
}
|
|
|
|
// Main function to detect activation events from SQL
|
|
export const detectActivationFromSql = async (sql: string): Promise<ActivationDetectionResult> => {
|
|
// Input validation
|
|
if (!sql || typeof sql !== 'string') {
|
|
return { detections: [] }
|
|
}
|
|
|
|
// Check SQL length
|
|
if (sql.length > MAX_SQL_LENGTH) {
|
|
return { detections: [] }
|
|
}
|
|
|
|
// Remove comments
|
|
const cleanedSql = removeCommentsFromSql(sql)
|
|
|
|
// Split by semicolons, but be careful with semicolons in strings
|
|
// The parser should handle this properly, but we'll split conservatively
|
|
const statements = splitSqlStatements(cleanedSql)
|
|
|
|
const detections: ActivationDetection[] = []
|
|
|
|
for (const statement of statements) {
|
|
const trimmedStatement = statement.trim()
|
|
if (!trimmedStatement) continue
|
|
|
|
const stmtDetections = await parseStatement(trimmedStatement)
|
|
detections.push(...stmtDetections)
|
|
}
|
|
|
|
return { detections }
|
|
}
|
|
|
|
// Split SQL into individual statements, respecting string literals
|
|
const splitSqlStatements = (sql: string): string[] => {
|
|
const statements: string[] = []
|
|
let currentStatement = ''
|
|
let inSingleQuote = false
|
|
let inDoubleQuote = false
|
|
let inDollarQuote = false
|
|
let dollarQuoteTag = ''
|
|
|
|
for (let i = 0; i < sql.length; i++) {
|
|
const char = sql[i]
|
|
const nextChar = sql[i + 1]
|
|
|
|
// Handle escape sequences
|
|
if (char === '\\' && (inSingleQuote || inDoubleQuote)) {
|
|
currentStatement += char
|
|
if (nextChar) {
|
|
currentStatement += nextChar
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Handle dollar quotes
|
|
if (char === '$' && !inSingleQuote && !inDoubleQuote) {
|
|
const dollarMatch = sql.slice(i).match(/^\$([^$]*)\$/)
|
|
if (dollarMatch) {
|
|
if (inDollarQuote && dollarQuoteTag === dollarMatch[1]) {
|
|
// End of dollar quote
|
|
inDollarQuote = false
|
|
dollarQuoteTag = ''
|
|
} else if (!inDollarQuote) {
|
|
// Start of dollar quote
|
|
inDollarQuote = true
|
|
dollarQuoteTag = dollarMatch[1]
|
|
}
|
|
currentStatement += dollarMatch[0]
|
|
i += dollarMatch[0].length - 1
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Handle regular quotes
|
|
if (!inDollarQuote) {
|
|
if (char === "'" && !inDoubleQuote) {
|
|
// Check for escaped single quote
|
|
if (nextChar === "'") {
|
|
currentStatement += "''"
|
|
i++
|
|
continue
|
|
}
|
|
inSingleQuote = !inSingleQuote
|
|
} else if (char === '"' && !inSingleQuote) {
|
|
inDoubleQuote = !inDoubleQuote
|
|
}
|
|
}
|
|
|
|
// Handle semicolons outside of quotes
|
|
if (char === ';' && !inSingleQuote && !inDoubleQuote && !inDollarQuote) {
|
|
if (currentStatement.trim()) {
|
|
statements.push(currentStatement.trim())
|
|
}
|
|
currentStatement = ''
|
|
continue
|
|
}
|
|
|
|
currentStatement += char
|
|
}
|
|
|
|
// Add the last statement if any
|
|
if (currentStatement.trim()) {
|
|
statements.push(currentStatement.trim())
|
|
}
|
|
|
|
return statements
|
|
} |