--- name: preventing-sql-injection description: Prevent SQL injection by using $queryRaw tagged templates instead of $queryRawUnsafe. Use when writing raw SQL queries or dynamic queries. allowed-tools: Read, Write, Edit, Grep --- # SQL Injection Prevention in Prisma 6 ## Overview SQL injection is one of the most critical security vulnerabilities in database applications. In Prisma 6, raw SQL queries must be written using `$queryRaw` tagged templates for automatic parameterization. **NEVER use `$queryRawUnsafe` with user input.** ## Critical Rules ### 1. ALWAYS Use $queryRaw Tagged Templates ```typescript const email = userInput; const users = await prisma.$queryRaw` SELECT * FROM "User" WHERE email = ${email} `; ``` Prisma automatically parameterizes `${email}` to prevent SQL injection. ### 2. NEVER Use $queryRawUnsafe with User Input ```typescript const email = userInput; const users = await prisma.$queryRawUnsafe( `SELECT * FROM "User" WHERE email = '${email}'` ); ``` **VULNERABLE TO SQL INJECTION** - attacker can inject: `' OR '1'='1` ### 3. Use Prisma.sql for Dynamic Queries ```typescript import { Prisma } from '@prisma/client'; const conditions: Prisma.Sql[] = []; if (email) { conditions.push(Prisma.sql`email = ${email}`); } if (status) { conditions.push(Prisma.sql`status = ${status}`); } const where = conditions.length > 0 ? Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}` : Prisma.empty; const users = await prisma.$queryRaw` SELECT * FROM "User" ${where} `; ``` ## Attack Vectors and Prevention ### Vector 1: String Concatenation in WHERE Clause **VULNERABLE:** ```typescript const searchTerm = req.query.search; const results = await prisma.$queryRawUnsafe( `SELECT * FROM "Product" WHERE name LIKE '%${searchTerm}%'` ); ``` **Attack:** `'; DELETE FROM "Product"; --` **SAFE:** ```typescript const searchTerm = req.query.search; const results = await prisma.$queryRaw` SELECT * FROM "Product" WHERE name LIKE ${'%' + searchTerm + '%'} `; ``` ### Vector 2: Dynamic Column Names **VULNERABLE:** ```typescript const sortColumn = req.query.sortBy; const users = await prisma.$queryRawUnsafe( `SELECT * FROM "User" ORDER BY ${sortColumn}` ); ``` **Attack:** `email; DROP TABLE "User"; --` **SAFE:** ```typescript const sortColumn = req.query.sortBy; const allowedColumns = ['email', 'name', 'createdAt']; if (!allowedColumns.includes(sortColumn)) { throw new Error('Invalid sort column'); } const users = await prisma.$queryRawUnsafe( `SELECT * FROM "User" ORDER BY ${sortColumn}` ); ``` **Note:** Column names cannot be parameterized, so use allowlist validation. ### Vector 3: Dynamic Table Names **VULNERABLE:** ```typescript const tableName = req.params.table; const data = await prisma.$queryRawUnsafe( `SELECT * FROM "${tableName}"` ); ``` **Attack:** `User" WHERE 1=1; DROP TABLE "Session"; --` **SAFE:** ```typescript const tableName = req.params.table; const allowedTables = ['User', 'Product', 'Order']; if (!allowedTables.includes(tableName)) { throw new Error('Invalid table name'); } const data = await prisma.$queryRawUnsafe( `SELECT * FROM "${tableName}"` ); ``` ### Vector 4: IN Clause with Arrays **VULNERABLE:** ```typescript const ids = req.body.ids.join(','); const users = await prisma.$queryRawUnsafe( `SELECT * FROM "User" WHERE id IN (${ids})` ); ``` **Attack:** `1) OR 1=1; --` **SAFE:** ```typescript const ids = req.body.ids; const users = await prisma.$queryRaw` SELECT * FROM "User" WHERE id IN (${Prisma.join(ids)}) `; ``` ### Vector 5: LIMIT and OFFSET Injection **VULNERABLE:** ```typescript const limit = req.query.limit; const offset = req.query.offset; const users = await prisma.$queryRawUnsafe( `SELECT * FROM "User" LIMIT ${limit} OFFSET ${offset}` ); ``` **Attack:** `10; DELETE FROM "User"; --` **SAFE:** ```typescript const limit = parseInt(req.query.limit, 10); const offset = parseInt(req.query.offset, 10); if (isNaN(limit) || isNaN(offset)) { throw new Error('Invalid pagination parameters'); } const users = await prisma.$queryRaw` SELECT * FROM "User" LIMIT ${limit} OFFSET ${offset} `; ``` ## Dynamic Query Building Patterns ### Pattern 1: Optional Filters ```typescript import { Prisma } from '@prisma/client'; interface SearchFilters { email?: string; status?: string; minAge?: number; } async function searchUsers(filters: SearchFilters) { const conditions: Prisma.Sql[] = []; if (filters.email) { conditions.push(Prisma.sql`email LIKE ${'%' + filters.email + '%'}`); } if (filters.status) { conditions.push(Prisma.sql`status = ${filters.status}`); } if (filters.minAge !== undefined) { conditions.push(Prisma.sql`age >= ${filters.minAge}`); } const where = conditions.length > 0 ? Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}` : Prisma.empty; return prisma.$queryRaw` SELECT * FROM "User" ${where} `; } ``` ### Pattern 2: Dynamic Sorting ```typescript type SortColumn = 'email' | 'name' | 'createdAt'; type SortOrder = 'ASC' | 'DESC'; async function getUsers(sortBy: SortColumn, order: SortOrder) { const allowedColumns: SortColumn[] = ['email', 'name', 'createdAt']; const allowedOrders: SortOrder[] = ['ASC', 'DESC']; if (!allowedColumns.includes(sortBy) || !allowedOrders.includes(order)) { throw new Error('Invalid sort parameters'); } return prisma.$queryRawUnsafe( `SELECT * FROM "User" ORDER BY ${sortBy} ${order}` ); } ``` ### Pattern 3: Complex JOIN with Dynamic Conditions ```typescript async function searchOrdersWithProducts( userId?: number, productName?: string, minTotal?: number ) { const conditions: Prisma.Sql[] = []; if (userId !== undefined) { conditions.push(Prisma.sql`o."userId" = ${userId}`); } if (productName) { conditions.push(Prisma.sql`p.name LIKE ${'%' + productName + '%'}`); } if (minTotal !== undefined) { conditions.push(Prisma.sql`o.total >= ${minTotal}`); } const where = conditions.length > 0 ? Prisma.sql`WHERE ${Prisma.join(conditions, ' AND ')}` : Prisma.empty; return prisma.$queryRaw` SELECT o.*, p.name as "productName" FROM "Order" o INNER JOIN "Product" p ON o."productId" = p.id ${where} ORDER BY o."createdAt" DESC `; } ``` ### Pattern 4: Batch Operations with Safe Arrays ```typescript async function updateUserStatuses( userIds: number[], newStatus: string ) { if (userIds.length === 0) { return []; } return prisma.$queryRaw` UPDATE "User" SET status = ${newStatus}, "updatedAt" = NOW() WHERE id IN (${Prisma.join(userIds)}) RETURNING * `; } ``` ## When $queryRawUnsafe is Acceptable `$queryRawUnsafe` is ONLY acceptable when: 1. **No user input involved** (static queries only) 2. **Identifiers from allowlist** (column/table names validated) 3. **Generated by type-safe builder** (internal tools, not user data) ```typescript async function getTableSchema(tableName: 'User' | 'Product' | 'Order') { return prisma.$queryRawUnsafe(` SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '${tableName}' `); } ``` **Still requires:** TypeScript literal type or runtime validation against allowlist. ## Migration from $queryRawUnsafe ### Before: ```typescript const status = req.query.status; const minAge = req.query.minAge; const users = await prisma.$queryRawUnsafe( `SELECT * FROM "User" WHERE status = '${status}' AND age >= ${minAge}` ); ``` ### After: ```typescript const status = req.query.status; const minAge = parseInt(req.query.minAge, 10); const users = await prisma.$queryRaw` SELECT * FROM "User" WHERE status = ${status} AND age >= ${minAge} `; ``` ## Testing for SQL Injection ### Test Case 1: Authentication Bypass ```typescript const maliciousEmail = "' OR '1'='1"; const user = await prisma.$queryRaw` SELECT * FROM "User" WHERE email = ${maliciousEmail} `; ``` **Expected:** Returns empty array (no match for literal string) ### Test Case 2: Comment Injection ```typescript const maliciousInput = "test'; --"; const users = await prisma.$queryRaw` SELECT * FROM "User" WHERE name = ${maliciousInput} `; ``` **Expected:** Searches for exact string `test'; --`, doesn't comment out rest of query ### Test Case 3: Union-Based Attack ```typescript const maliciousId = "1 UNION SELECT password FROM Admin"; const user = await prisma.$queryRaw` SELECT * FROM "User" WHERE id = ${maliciousId} `; ``` **Expected:** Type error or no results (string cannot match integer id column) ## Detection and Remediation ### Detection Patterns Use grep to find vulnerable code: ```bash grep -r "\$queryRawUnsafe" --include="*.ts" grep -r "queryRawUnsafe.*\${" --include="*.ts" grep -r "queryRawUnsafe.*req\." --include="*.ts" ``` ### Automated Detection ```typescript import { ESLint } from 'eslint'; const dangerousPatterns = [ /\$queryRawUnsafe\s*\([^)]*\$\{/, /queryRawUnsafe\s*\([^)]*req\./, /queryRawUnsafe\s*\([^)]*params\./, /queryRawUnsafe\s*\([^)]*query\./, /queryRawUnsafe\s*\([^)]*body\./, ]; ``` ### Remediation Checklist - [ ] Replace all `$queryRawUnsafe` with `$queryRaw` where user input exists - [ ] Use `Prisma.sql` for dynamic query building - [ ] Validate column/table names against allowlists - [ ] Parameterize all user inputs - [ ] Parse numeric inputs before use - [ ] Use `Prisma.join()` for array parameters - [ ] Add SQL injection test cases - [ ] Run security audit tools ## Related Skills **Security Best Practices:** - If sanitizing user inputs before database operations, use the sanitizing-user-inputs skill from typescript for input sanitization patterns ## Resources - [Prisma Raw Database Access Docs](https://www.prisma.io/docs/orm/prisma-client/queries/raw-database-access) - [OWASP SQL Injection Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html) - [Prisma Security Best Practices](https://www.prisma.io/docs/orm/prisma-client/queries/raw-database-access#sql-injection) ## Summary - **ALWAYS** use `$queryRaw` tagged templates for user input - **NEVER** use `$queryRawUnsafe` with untrusted data - **USE** `Prisma.sql` and `Prisma.join()` for dynamic queries - **VALIDATE** column/table names against allowlists - **TEST** for common SQL injection attack vectors - **AUDIT** codebase regularly for `$queryRawUnsafe` usage