Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:22:25 +08:00
commit c3294f28aa
60 changed files with 10297 additions and 0 deletions

View File

@@ -0,0 +1,441 @@
---
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