Consultas preparadas, seguridad y validacion en backend
Serie: Desarrollo de Interfaces
Curso: Desarrollo de Interfaces 1
Capítulo 22: Consultas preparadas, seguridad y validación en backend
Capítulo anterior: CRUD con MySQL desde Node.js
Capítulo siguiente: Introducción a POO: objetos, clases y responsabilidades
Cuando una aplicación ya puede crear, listar, editar y eliminar datos, aparece una pregunta importante: ¿qué tan confiable es ese backend cuando recibe información real de personas reales?
En una clase o en una práctica pequeña, solemos probar la API con datos perfectos: nombres completos, correos bien escritos, identificadores válidos y formularios obedientes. Pero una interfaz profesional debe prepararse para otro escenario: campos vacíos, números donde deberían ir textos, textos demasiado largos, usuarios que se equivocan y solicitudes maliciosas que intentan romper la consulta SQL.
Por eso este capítulo funciona como cierre de seguridad básica para el bloque CRUD con MySQL. Antes de pasar a programación orientada a objetos, necesitamos ordenar una idea fundamental: el backend no debe confiar ciegamente en lo que recibe.
Qué aprenderás hoy
- Por qué concatenar valores dentro de una consulta SQL es peligroso.
- Cómo usar consultas preparadas con
mysql2/promise. - Cómo validar parámetros y datos del cuerpo de una solicitud.
- Cómo devolver errores claros para la interfaz sin exponer detalles internos.
- Cómo organizar reglas simples antes de guardar información en MySQL.
- Qué prácticas conviene adoptar desde los primeros proyectos backend.
La seguridad no empieza cuando el proyecto ya está grande. Empieza cuando decides no mezclar datos externos directamente con instrucciones SQL.
El problema: datos externos dentro de una consulta
Imagina una ruta que busca un proyecto por su identificador. Si escribimos la consulta concatenando el valor recibido desde la URL, el código puede parecer rápido y comprensible:
app.get('/projects/:id', async (request, response) => {
const sql = 'SELECT id, name, status FROM projects WHERE id = ' + request.params.id;
const [rows] = await pool.query(sql);
response.json(rows[0]);
});
El problema es que request.params.id viene de fuera. Si alguien envía un valor inesperado, ese valor termina formando parte del texto SQL. En el mejor caso, la consulta fallará. En el peor, una persona podría intentar manipular la consulta para obtener o alterar información que no debería tocar.
OWASP recomienda usar consultas parametrizadas o preparadas como defensa principal contra la inyección SQL. La idea es sencilla: el código SQL y los valores viajan separados. La base de datos recibe la estructura de la consulta por un lado y los datos por otro.
Consultas preparadas con mysql2
Con mysql2/promise podemos usar execute y marcadores ?. Cada signo de interrogación representa un valor que será enviado como parámetro, no como parte libre del texto SQL.
app.get('/projects/:id', async (request, response) => {
const id = Number(request.params.id);
const [rows] = await pool.execute(
'SELECT id, name, status FROM projects WHERE id = ?',
[id]
);
response.json(rows[0]);
});
La diferencia parece pequeña, pero es enorme. El motor de base de datos ya no interpreta el valor como fragmento de SQL. Lo trata como dato.
Usa execute con parámetros para valores dinámicos: identificadores, búsquedas, filtros, datos de formularios y valores que llegan desde el cliente.
Validar parámetros antes de consultar
Aunque las consultas preparadas ayudan a proteger la base de datos, no reemplazan la validación. Si una ruta espera un número entero positivo, conviene verificarlo antes de ejecutar la consulta.
function parseId(value) {
const id = Number(value);
if (!Number.isInteger(id) || id <= 0) {
return null;
}
return id;
}
Ahora la ruta puede responder con un error claro cuando el identificador no tiene sentido:
app.get('/projects/:id', async (request, response) => {
const id = parseId(request.params.id);
if (!id) {
return response.status(400).json({
error: 'El identificador del proyecto no es valido.'
});
}
const [rows] = await pool.execute(
'SELECT id, name, status FROM projects WHERE id = ?',
[id]
);
if (rows.length === 0) {
return response.status(404).json({
error: 'Proyecto no encontrado.'
});
}
response.json(rows[0]);
});
Esta validación mejora dos cosas al mismo tiempo. Primero, evita trabajo innecesario en la base de datos. Segundo, ayuda a que la interfaz sepa qué ocurrió y pueda mostrar un mensaje útil.
Validar el cuerpo de una solicitud
En una operación POST o PUT, el cliente envía datos en el cuerpo de la solicitud. Para un proyecto simple podemos validar manualmente sin instalar todavía una librería adicional.
function validateProjectInput(body) {
const errors = {};
const data = {
name: String(body.name || '').trim(),
description: String(body.description || '').trim(),
status: String(body.status || 'draft').trim()
};
const allowedStatuses = new Set(['draft', 'review', 'published']);
if (data.name.length < 3) {
errors.name = 'El nombre debe tener al menos 3 caracteres.';
}
if (data.name.length > 120) {
errors.name = 'El nombre no debe superar los 120 caracteres.';
}
if (data.description.length > 500) {
errors.description = 'La descripcion no debe superar los 500 caracteres.';
}
if (!allowedStatuses.has(data.status)) {
errors.status = 'El estado enviado no es valido.';
}
return {
data,
errors,
isValid: Object.keys(errors).length === 0
};
}
La validación debe ser concreta. No basta con decir “dato inválido”. Si la interfaz recibe un objeto con errores por campo, puede mostrar mensajes al lado de cada input.
Crear un registro de forma más segura
Con la función anterior, una ruta de creación podría quedar así:
app.post('/projects', async (request, response) => {
const result = validateProjectInput(request.body);
if (!result.isValid) {
return response.status(400).json({
error: 'Revisa los datos enviados.',
fields: result.errors
});
}
const { name, description, status } = result.data;
const [dbResult] = await pool.execute(
`INSERT INTO projects (name, description, status)
VALUES (?, ?, ?)`,
[name, description, status]
);
response.status(201).json({
id: dbResult.insertId,
name,
description,
status
});
});
Observa el orden: primero validamos, luego ejecutamos una consulta preparada y finalmente devolvemos una respuesta JSON clara. Esa secuencia debería volverse costumbre.
No todo se puede parametrizar igual
Los parámetros funcionan muy bien para valores, pero no para cualquier parte de una consulta. Por ejemplo, si permites ordenar por una columna enviada desde la URL, no deberías colocar ese texto libremente dentro del SQL.
const allowedSortFields = {
name: 'name',
created: 'created_at',
status: 'status'
};
const sort = allowedSortFields[request.query.sort] || 'created_at';
const [rows] = await pool.query(
`SELECT id, name, status FROM projects ORDER BY ${sort} DESC`
);
Aquí usamos una lista permitida. El usuario puede pedir name, created o status, pero el SQL solo recibirá nombres de columna definidos por nuestra aplicación.
Si una parte de la consulta no puede enviarse como parámetro, usa una lista permitida. Nunca copies directamente columnas, tablas u órdenes recibidos desde el navegador.
Manejo de errores sin mostrar detalles internos
Cuando algo falla en el servidor, la interfaz necesita una respuesta. Pero eso no significa que debamos mostrar el mensaje exacto de MySQL, rutas internas o detalles de configuración.
Express permite centralizar errores con un middleware. Una forma simple de envolver rutas asíncronas es usar una función auxiliar:
function asyncHandler(controller) {
return function (request, response, next) {
Promise.resolve(controller(request, response, next)).catch(next);
};
}
Luego podemos usarla en nuestras rutas:
app.get('/projects/:id', asyncHandler(async (request, response) => {
const id = parseId(request.params.id);
if (!id) {
return response.status(400).json({ error: 'Identificador no valido.' });
}
const [rows] = await pool.execute(
'SELECT id, name, status FROM projects WHERE id = ?',
[id]
);
if (rows.length === 0) {
return response.status(404).json({ error: 'Proyecto no encontrado.' });
}
response.json(rows[0]);
}));
Al final del archivo, después de las rutas, agregamos el middleware de error:
app.use(function (error, request, response, next) {
console.error(error);
response.status(500).json({
error: 'Ocurrio un problema en el servidor.'
});
});
En desarrollo podemos mirar la consola. En producción, conviene registrar errores de forma más ordenada, pero sin exponerlos al usuario final.
Códigos HTTP que la interfaz puede entender
Los códigos de estado no son decoración. Ayudan a que el frontend reaccione correctamente:
400: la solicitud tiene datos inválidos.401: falta autenticación.403: el usuario no tiene permiso.404: el recurso no existe.409: hay conflicto, por ejemplo un correo duplicado.500: ocurrió un error inesperado en el servidor.
Una buena interfaz no debería tratar todos los errores como si fueran iguales. Si el backend responde con intención, el frontend puede mostrar mejores mensajes.
Checklist mínimo antes de publicar una API CRUD
- Usar consultas preparadas para valores dinámicos.
- Validar parámetros de ruta, query string y cuerpo de la solicitud.
- Limitar textos demasiado largos.
- Usar listas permitidas para estados, columnas y opciones cerradas.
- No exponer errores internos de la base de datos.
- No subir archivos
.envcon contraseñas reales. - Usar usuarios de base de datos con permisos razonables.
- Responder con códigos HTTP coherentes.
Toma el CRUD con MySQL del capítulo anterior y agrega validación para crear y editar proyectos. Luego intenta enviar datos vacíos, textos demasiado largos y un identificador inválido desde Postman, Thunder Client o una interfaz propia.
Errores comunes
- Creer que validar en el frontend es suficiente. El backend también debe validar.
- Concatenar valores dentro del SQL porque “solo es una práctica”.
- Mostrar al usuario mensajes internos como nombres de tablas o errores completos de MySQL.
- Aceptar cualquier campo enviado por el cliente sin filtrar.
- No diferenciar entre errores de usuario y errores del servidor.
Próximo capítulo
Con consultas preparadas, validación y manejo de errores, nuestro backend ya tiene una base más seria. El siguiente paso será mirar el código desde otra perspectiva: programación orientada a objetos. Veremos cómo los objetos, clases y responsabilidades pueden ayudarnos a ordenar la lógica de una aplicación sin convertirla en una maraña difícil de mantener.