Esta lección cierra las promesas que el curso lleva haciendo desde el módulo 5. Trata dos cosas que parecen distintas y son la misma: quién decide qué se ejecuta y quién puede ver qué. La primera mitad es la inyección SQL, el fallo de seguridad más viejo y más caro de las aplicaciones con base de datos, explicado desde el lado que importa: cómo se evita. La segunda es el sistema de permisos y roles de PostgreSQL, incluida la seguridad a nivel de fila, con un diseño concreto para TiendaVerde.
Un aviso desde el principio: esto es una lección defensiva. Los ejemplos de ataque que verás son mínimos e inofensivos —una condición que devuelve de más— y están aquí solo para que entiendas por qué falla el enfoque vulnerable. El peso de la lección está en las defensas. Y una advertencia que va en serio: antes de exponer a Internet un sistema con datos reales, encarga una revisión a un profesional de seguridad y consulta el tratamiento de los datos personales con el responsable legal o de protección de datos de tu organización. Lo que sigue es el mínimo que debes saber, no un sustituto de esa revisión.
Contenido
- Qué es una inyección SQL
- Por qué ocurre: datos y código en la misma cadena
- La defensa: consultas parametrizadas
- Lo que no es una defensa suficiente
- El caso especial: identificadores dinámicos
- Defensa en profundidad
- Permisos y roles en PostgreSQL
- Un diseño de roles para TiendaVerde
- Row Level Security
SECURITY DEFINERy elsearch_path- Datos personales
- Checklist de seguridad
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Qué es una inyección SQL
El buscador de la tienda recibe un texto y busca productos. Escrito de la peor manera posible —concatenando— queda así:
# ⚠️ VULNERABLE. No escribas esto nunca.
termino = request.args["q"]
sql = "SELECT id, nombre, precio FROM productos WHERE activo AND nombre ILIKE '%" + termino + "%'"
cursor.execute(sql)Con la entrada normal aceite, lo que llega al servidor es SELECT id, nombre, precio FROM productos WHERE activo AND nombre ILIKE '%aceite%';, que es correcto y devuelve lo esperado:
| id | nombre | precio |
|---|---|---|
| 1 | Aceite de oliva virgen extra 500 ml | 12.50 |
| 8 | Aceite corporal de almendras 200 ml | 14.25 |
Dos productos. Ahora, la entrada ' OR '1'='1 — un texto perfectamente escribible en cualquier caja de búsqueda. Al concatenarlo, la consulta que se ejecuta es:
Devuelve 19 filas: el catálogo activo entero. La comilla del usuario cerró la cadena que el programador había abierto, y lo que venía detrás dejó de ser texto de búsqueda para convertirse en estructura de la consulta: un OR que nadie escribió. Con la variante ' OR '1'='1' --, el doble guion comenta el resto de la línea, incluido el %' pendiente:
20 filas. Una más: aparecen también las cápsulas de espirulina, el producto descatalogado (activo = FALSE) que la tienda no debería mostrar jamás. Es un ejemplo diminuto y sin daño, y precisamente por eso es útil: el atacante no ha hecho nada raro; ha escrito texto en una caja de texto, y ha conseguido que el filtro de negocio deje de aplicarse. Si en lugar de un catálogo público la consulta hubiera sido la de "mis pedidos" o la de "empleados", el resultado habría sido el mismo mecanismo sobre datos que sí importan.
Y una consecuencia menos obvia pero igual de grave: la entrada O'Connor —un apellido perfectamente normal— rompe la consulta con un error de sintaxis. La misma grieta que permite el ataque hace que el programa falle con datos legítimos. Un código vulnerable a inyección es también un código que no funciona bien.
- Por qué ocurre: datos y código en la misma cadena
La causa cabe en una frase, y conviene aprendérsela:
La inyección SQL ocurre porque el código y los datos viajan mezclados en la misma cadena de texto, y el motor no tiene forma de saber qué parte escribiste tú y qué parte escribió el usuario.
El servidor recibe un texto y lo analiza entero. Para él, OR '1'='1' es lenguaje SQL igual que el SELECT que lo precede: no hay ninguna marca que diga "esto venía de un formulario". Todo lo que se deriva de ahí —añadir condiciones, comentar el resto, encadenar sentencias si el conector lo permite— es consecuencia de esa mezcla. De ahí sale la única defensa que funciona de verdad: separar el código de los datos, y que sea el motor quien los junte sabiendo cuál es cuál.
flowchart LR
A["plantilla SQL"] --> C["⚠️ una sola cadena"] --> D["el analizador ve UN texto:<br/>no distingue código de dato"]
B["entrada del usuario"] --> C
E["✅ SQL con marcadores $1, ?"] --> G["el motor analiza y<br/>planifica <b>primero</b>"] --> H["y solo después recibe los<br/>valores como <b>datos tipados</b>"]
F["entrada del usuario"] --> H
- La defensa: consultas parametrizadas
Una consulta parametrizada (o sentencia preparada) manda al servidor dos cosas separadas: el texto con marcadores, y los valores. El servidor analiza y planifica antes de ver los valores; cuando llegan, ya no hay nada que analizar y un valor no puede convertirse en código pase lo que pase. Ni siquiera hace falta escapar comillas: el valor no se inserta en el texto. El mismo buscador, bien escrito, en cuatro entornos:
# Python — psycopg 3
cur.execute("SELECT id, nombre, precio FROM productos WHERE activo AND nombre ILIKE %s",
('%' + termino + '%',)) # el comodín va en el VALOR, no en el SQL// Java — JDBC
PreparedStatement ps = conn.prepareStatement(
"SELECT id, nombre, precio FROM productos WHERE activo AND nombre ILIKE ?");
ps.setString(1, "%" + termino + "%");
ResultSet rs = ps.executeQuery();// PHP — PDO (con emulación de preparadas DESACTIVADA)
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$st = $pdo->prepare("SELECT id, nombre, precio FROM productos WHERE activo AND nombre ILIKE :q");
$st->execute([':q' => '%' . $termino . '%']);// Node.js — node-postgres
const { rows } = await client.query(
'SELECT id, nombre, precio FROM productos WHERE activo AND nombre ILIKE $1',
[`%${termino}%`]);Con la entrada maliciosa ' OR '1'='1, las cuatro versiones devuelven 0 filas: buscan, literalmente, productos cuyo nombre contenga el texto ' OR '1'='1. Que no exista ninguno es exactamente lo correcto. El ataque deja de ser un ataque y pasa a ser una búsqueda sin resultados.
Y el mecanismo también existe en SQL puro, útil para verlo sin lenguaje de por medio:
PREPARE buscar_producto (text) AS
SELECT id, nombre, precio FROM productos WHERE activo AND nombre ILIKE $1;
EXECUTE buscar_producto('%aceite%'); -- 2 filas
EXECUTE buscar_producto('%'' OR ''1''=''1%'); -- 0 filas: es solo texto
DEALLOCATE buscar_producto;Dos matices prácticos. El comodín % va dentro del valor, nunca en el texto SQL: ILIKE '%' || $1 || '%' también funciona, pero entonces conviene escapar los % y _ que traiga el usuario, o una búsqueda de 100% se convertirá en un comodín. Y si vas a filtrar por una lista de valores, no montes la lista concatenando: usa WHERE id = ANY($1) pasando un array, que es lo que soportan todos los conectores modernos.
Nota de dialecto. El marcador cambia según el conector:
$1, $2en PostgreSQL nativo y node-postgres,%sen psycopg,?en JDBC, PDO y muchos otros,:nombrepara parámetros con nombre en PDO, SQLAlchemy o JDBI. Lo que no cambia es la garantía: mientras el valor viaje por el canal de parámetros, no puede convertirse en código. Ojo con PDO: por omisión emula las preparadas escapando en el cliente; desactívalo conATTR_EMULATE_PREPARES => falsepara usar las de verdad.
- Lo que no es una defensa suficiente
| Falsa defensa | Por qué falla |
|---|---|
| Escapar comillas a mano | Tienes que acertar con la codificación, los comodines, los comentarios y cada caso límite del dialecto — cada vez, en cada consulta y para siempre. Un despiste basta. Y no protege en absoluto donde no hay comillas: un parámetro numérico concatenado (WHERE id = + entrada) es inyectable sin usar ni una |
Listas negras de palabras (DROP, UNION, --) |
Rechazan búsquedas legítimas ("union europea", "drop de agua") y no cubren lo que no imaginaste. Filtrar por lo prohibido siempre pierde frente a filtrar por lo permitido |
| Ocultar los mensajes de error | Es buena idea, pero como capa adicional: reduce la información que se filtra, no cierra el agujero. La vulnerabilidad sigue ahí, solo que a ciegas |
| Validar en el navegador / "es una intranet" | Cualquiera puede llamar a la API sin pasar por tu página; y la mayoría de los incidentes vienen de dentro o de una cuenta comprometida |
| Un ORM, sin más | Protege el 95 % de lo que hace por ti, y deja de protegerte en cuanto usas su raw() / text() / createNativeQuery() concatenando (11-05) |
La lista deja una conclusión: no hay grados. O el valor viaja por el canal de parámetros, o no está protegido.
- El caso especial: identificadores dinámicos
Hay un caso que las consultas parametrizadas no resuelven, y es por donde se cuela la inyección en aplicaciones por lo demás correctas: un parámetro no puede ser el nombre de una tabla, de una columna, ni el sentido de un ORDER BY.
# ⚠️ VULNERABLE: la columna de ordenación viene de la URL (?orden=precio&dir=desc)
sql = f"SELECT id, nombre, precio FROM productos ORDER BY {orden} {direccion}"
# ✅ CORRECTA: lista blanca. La entrada del usuario elige una CLAVE, no aporta el SQL.
COLUMNAS = {"nombre": "p.nombre", "precio": "p.precio", "fecha": "p.fecha_alta"}
DIRECCION = {"asc": "ASC", "desc": "DESC"}
col = COLUMNAS.get(orden, "p.id") # valor por defecto seguro si no está en la lista
dir_ = DIRECCION.get(direccion, "ASC")
sql = f"SELECT p.id, p.nombre, p.precio FROM productos AS p ORDER BY {col} {dir_}"La idea clave: el usuario no aporta texto, elige una opción de un conjunto que tú controlas. Lo que se concatena nunca sale de la entrada; sale de tu diccionario. Y como efecto secundario, el valor por defecto convierte un parámetro basura en un orden razonable en lugar de un error.
Cuando el SQL dinámico se construye dentro de PostgreSQL —en una función PL/pgSQL (10-04)— la herramienta es format() con sus marcadores de tipo:
CREATE OR REPLACE FUNCTION fn_contar_filas(p_tabla text) RETURNS bigint AS $$
DECLARE v_total bigint;
BEGIN
-- %I = IDENTIFICADOR (entrecomillado si hace falta) · %L = LITERAL · %s = texto crudo (⚠️)
EXECUTE format('SELECT COUNT(*) FROM %I', p_tabla) INTO v_total;
RETURN v_total;
END;
$$ LANGUAGE plpgsql;
SELECT fn_contar_filas('lineas_pedido');| fn_contar_filas |
|---|
| 47 |
%I aplica quote_ident: entrecomilla el identificador y neutraliza cualquier cosa rara que traiga. Nunca uses %s con entrada del usuario; %s es concatenación con otro nombre. Y aun con %I, lo correcto es validar antes contra la lista de tablas permitidas o contra information_schema: %I impide la inyección, pero no impide que alguien cuente las filas de una tabla que no le corresponde.
- Defensa en profundidad
Ninguna capa basta sola; la buena noticia es que se suman.
| Capa | Qué hace | En la práctica |
|---|---|---|
| Parametrizar | Cierra la inyección | Obligatorio, sin excepciones |
| Validar la entrada | Rechaza lo absurdo antes de tocar la base | Tipos, longitudes, rangos, formato, listas blancas. id es un entero: conviértelo y falla si no lo es |
| Mínimo privilegio | Limita el daño si algo se escapa | La web no se conecta como superusuario (apartado 8) |
| Errores discretos | No regala el esquema | Al usuario, "no se ha podido completar la operación" + identificador de incidencia; el detalle, al registro del servidor |
| Registro y alertas | Permite detectarlo | Registrar los fallos de sintaxis repetidos: son la firma de alguien probando |
| Revisión y pruebas | Lo encuentra antes que otros | Buscar concatenaciones en la revisión de código; análisis estático; pruebas con ' y -- en cada campo |
Sobre los mensajes de error: en producción, el error completo de PostgreSQL le dice al usuario el nombre de las tablas, de las columnas y de las restricciones. Registra el detalle en el servidor, devuelve un texto genérico y un identificador que permita a soporte encontrar la traza; en desarrollo, al revés.
- Permisos y roles en PostgreSQL
Aquí se cierran las promesas de 05-04, 09-05, 10-01 y 10-04. El modelo es más simple de lo que parece, con una idea central:
En PostgreSQL no hay "usuarios" y "grupos": solo hay roles. Un rol con
LOGINse comporta como un usuario; un rol sinLOGINal que se conceden otros roles se comporta como un grupo. Es el mismo objeto.
CREATE ROLE tv_lectura; -- grupo (sin LOGIN)
CREATE ROLE ana LOGIN PASSWORD 'unaclavelarga'; -- usuario
GRANT tv_lectura TO ana; -- ana hereda los permisos del grupoLos privilegios se conceden con GRANT, se retiran con REVOKE y se aplican a distintos niveles:
| Nivel | Privilegios habituales | Ejemplo |
|---|---|---|
| Base de datos | CONNECT, CREATE, TEMPORARY |
GRANT CONNECT ON DATABASE tiendaverde TO tv_lectura; |
| Esquema | USAGE (poder ver los objetos), CREATE |
GRANT USAGE ON SCHEMA public TO tv_lectura; |
| Tabla / vista | SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER |
GRANT SELECT ON pedidos TO tv_lectura; |
| Columna | SELECT (col, …), UPDATE (col, …) |
GRANT SELECT (id, nombre, ciudad, pais) ON clientes TO tv_lectura; |
| Secuencia / función | USAGE, SELECT, UPDATE / EXECUTE |
Las secuencias hacen falta para INSERT en tablas con IDENTITY |
Dos trampas que pillan a todo el mundo:
USAGEsobre el esquema es imprescindible. Sin él,GRANT SELECT ON pedidosno sirve de nada: el rol tiene permiso sobre una tabla que no puede alcanzar. El error espermission denied for schema publicy desconcierta porque elGRANTde la tabla existe.ALTER DEFAULT PRIVILEGES, o los objetos nuevos no heredan nada. UnGRANT SELECT ON ALL TABLES IN SCHEMA publicafecta a las tablas que existen en ese momento. La tabla que crees mañana no estará incluida, y el fallo aparecerá en producción después del despliegue:
GRANT SELECT ON ALL TABLES IN SCHEMA public TO tv_lectura; -- las de hoy
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO tv_lectura; -- y las de mañanaOjo: ALTER DEFAULT PRIVILEGES se aplica a los objetos que cree el rol que ejecuta la orden. Si las migraciones las lanza tv_admin, hay que ejecutarlo como tv_admin (o con FOR ROLE tv_admin).
PUBLIC y el esquema public
Son dos cosas distintas con el mismo nombre, y confundirlas es el clásico de la lección. PUBLIC es un pseudo-rol que significa "todos los roles que existan y existirán". public es el esquema por omisión donde viven las nueve tablas de TiendaVerde.
Históricamente, PUBLIC tenía CREATE sobre el esquema public, de modo que cualquier usuario podía crear objetos allí. PostgreSQL 15 lo corrigió, pero en bases creadas antes, o migradas, conviene comprobarlo y endurecerlo:
REVOKE CREATE ON SCHEMA public FROM PUBLIC; -- que solo cree quien deba
REVOKE ALL ON DATABASE tiendaverde FROM PUBLIC; -- incluido CONNECTY recuerda del módulo 10 que una vista solo protege si quitas el permiso sobre la tabla: GRANT SELECT ON v_clientes_publico no sirve de nada mientras el rol conserve SELECT ON clientes.
- Un diseño de roles para TiendaVerde
Tres roles de grupo, tres perfiles reales, y nadie conectándose como propietario de las tablas:
| Rol | Para quién | Puede | No puede |
|---|---|---|---|
tv_lectura |
Analistas, cuadros de mando, herramientas de BI | SELECT sobre las tablas de negocio y las vistas; sin la columna email de clientes |
Escribir nada; ver salarios; ver correos |
tv_app |
La aplicación web | SELECT, INSERT, UPDATE sobre lo operativo; EXECUTE de sp_confirmar_pedido |
DELETE, DDL, tocar empleados |
tv_admin |
Migraciones y despliegues | Todo el DDL sobre el esquema | Es el propietario: no se usa desde la aplicación |
REVOKE ALL ON DATABASE tiendaverde FROM PUBLIC;
GRANT CONNECT ON DATABASE tiendaverde TO tv_lectura, tv_app, tv_admin;
GRANT USAGE ON SCHEMA public TO tv_lectura, tv_app;
-- tv_lectura: solo lectura; en clientes solo las columnas no sensibles.
-- empleados NO se concede en absoluto: contiene salarios.
GRANT SELECT ON categorias, proveedores, productos, pedidos, lineas_pedido, resenas, devoluciones
TO tv_lectura;
GRANT SELECT (id, nombre, apellidos, ciudad, pais, fecha_registro) ON clientes TO tv_lectura;
-- tv_app: lo que necesita la web, ni un privilegio más.
-- Sin DELETE: la web marca como cancelado, no borra (borrado lógico, 05-04).
GRANT SELECT ON categorias, proveedores, productos, empleados TO tv_app;
GRANT SELECT, INSERT, UPDATE ON clientes, pedidos, lineas_pedido, resenas TO tv_app;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO tv_app; -- imprescindible para los IDENTITY
GRANT EXECUTE ON PROCEDURE sp_confirmar_pedido(int, int, int[], int[]) TO tv_app;
-- Los objetos que cree tv_admin mañana heredarán estos permisos
ALTER DEFAULT PRIVILEGES FOR ROLE tv_admin IN SCHEMA public GRANT SELECT ON TABLES TO tv_lectura;
ALTER DEFAULT PRIVILEGES FOR ROLE tv_admin IN SCHEMA public
GRANT SELECT, INSERT, UPDATE ON TABLES TO tv_app;
-- Personas y servicios, con LOGIN, heredando del grupo
CREATE ROLE daniel LOGIN PASSWORD '...'; GRANT tv_lectura TO daniel; -- analista de datos
CREATE ROLE web_prod LOGIN PASSWORD '...'; GRANT tv_app TO web_prod;Comprobar qué tiene cada rol es tan importante como concederlo:
SELECT grantee, table_name, string_agg(privilege_type, ', ' ORDER BY privilege_type) AS privilegios
FROM information_schema.role_table_grants
WHERE grantee IN ('tv_lectura','tv_app') AND table_name = 'pedidos'
GROUP BY grantee, table_name ORDER BY grantee;| grantee | table_name | privilegios |
|---|---|---|
| tv_app | pedidos | INSERT, SELECT, UPDATE |
| tv_lectura | pedidos | SELECT |
En psql, \dp pedidos da la misma información de forma compacta. La regla de oro: la aplicación nunca se conecta como superusuario ni como propietario de las tablas. Si web_prod es propietario, todos los REVOKE del mundo son decorativos: el propietario puede reconcedérselo todo.
- Row Level Security
Los permisos anteriores llegan hasta la tabla y la columna. Row Level Security (RLS) llega hasta la fila: permite que dos usuarios ejecuten SELECT * FROM pedidos y obtengan resultados distintos. El caso clásico: que cada comercial vea solo sus pedidos.
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;
CREATE POLICY pol_pedidos_comercial ON pedidos FOR SELECT TO tv_comercial
USING (empleado_id = current_setting('app.empleado_id', true)::int);Con SET app.empleado_id = '4' al inicio de la sesión, el comercial Óscar (empleado 4) ve 4 pedidos —los números 2, 6, 10 y 16— y ningún otro, aunque escriba SELECT * FROM pedidos sin WHERE. Los diez pedidos web, con empleado_id NULL, no los ve nadie con esta política, porque NULL = 4 no es cierto: si deben verlos todos, la condición sería empleado_id = ... OR empleado_id IS NULL, y esa decisión hay que tomarla explícitamente.
| Filtrar en la aplicación | RLS | |
|---|---|---|
| Dónde vive la regla | En cada consulta de cada pantalla | En la tabla, una sola vez |
Si alguien olvida el WHERE |
Fuga de datos | No pasa nada |
Acceso desde psql o un informe |
Sin protección | Protegido |
| Coste | Ninguno | La condición se añade a todas las consultas: puede impedir planes buenos |
| Depuración | Sencilla | "¿Por qué no veo mi fila?" es una pregunta difícil |
Tres avisos. El propietario de la tabla y los superusuarios se saltan RLS por omisión (hace falta FORCE ROW LEVEL SECURITY). Las políticas se escriben por operación —FOR SELECT, FOR INSERT con WITH CHECK, FOR UPDATE, FOR ALL— y si no defines la de INSERT, no se puede insertar nada. Y el coste de rendimiento es real: la condición entra en todas las consultas, así que la columna de la política debe estar indexada. RLS brilla en multi-inquilino (empresa_id); para tres pantallas y un rol, suele bastar una vista.
SECURITY DEFINER y el search_path
SECURITY DEFINER y el search_pathCierra 10-04. Por omisión una función se ejecuta con los permisos de quien la llama (SECURITY INVOKER). Con SECURITY DEFINER se ejecuta con los de quien la creó, lo que permite dar acceso controlado a datos que el usuario no puede leer: por ejemplo, que un analista obtenga la media salarial sin poder leer empleados.
CREATE OR REPLACE FUNCTION fn_salario_medio() RETURNS numeric
LANGUAGE sql SECURITY DEFINER
SET search_path = public, pg_temp -- ⬅️ IMPRESCINDIBLE
AS $$ SELECT ROUND(AVG(salario), 2) FROM empleados; $$;
REVOKE EXECUTE ON FUNCTION fn_salario_medio() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION fn_salario_medio() TO tv_lectura;
SELECT fn_salario_medio();| fn_salario_medio |
|---|
| 35037.50 |
La media salarial canónica del curso, 35 037,50 €, servida sin dar acceso a la tabla. Y ahora el riesgo, que es serio: una función SECURITY DEFINER corre con privilegios ajenos, así que si el atacante controla qué objetos resuelve dentro de ella, ejecuta código con esos privilegios. El vector es el search_path: si la función dice FROM empleados sin cualificar y el llamante ha puesto por delante un esquema suyo con una tabla empleados propia, la función leerá la del atacante.
Las cuatro reglas, y no son opcionales: (1) fija siempre SET search_path = public, pg_temp en toda función SECURITY DEFINER (o cualifica cada objeto: public.empleados); (2) REVOKE EXECUTE ... FROM PUBLIC y concede solo a quien deba, porque por omisión EXECUTE se concede a PUBLIC; (3) haz la función lo más pequeña posible y sin SQL dinámico dentro —y si lo hay, format('%I'/'%L') y lista blanca—; (4) úsala solo cuando haga falta: SECURITY INVOKER es el valor por omisión por una buena razón.
- Datos personales
TiendaVerde es ficticia, pero su esquema tiene exactamente los datos que en un sistema real están regulados:
| Columna | Qué es | Tratamiento |
|---|---|---|
clientes.nombre, apellidos |
Identifican a una persona | Acceso restringido; seudonimizar en pruebas |
clientes.email |
Identificador directo | Fuera de tv_lectura; nunca en un CSV que circule |
clientes.ciudad, pais |
Localización aproximada | Suele bastar para análisis; el detalle, no |
empleados.salario |
Dato laboral sensible | Solo para quien lo necesite; SECURITY DEFINER para agregados |
pedidos, lineas_pedido |
Perfil de consumo de una persona identificada | Agregados sí; detalle nominal, restringido |
resenas.comentario |
Texto libre: puede contener cualquier cosa | Revisar antes de exportar |
Las medidas que hay que conocer, sin entrar en materia jurídica:
- Cifrado en tránsito: TLS obligatorio en la conexión (
sslmode=requireo superior); sin él, credenciales y datos viajan en claro. En reposo: cifrado de disco o de volumen, y de las copias de seguridad, que es lo que más se olvida. - Mínimo privilegio, que es lo del apartado 8: la mayoría de los analistas no necesita ver correos. Y registro de accesos a los datos sensibles, no solo de los cambios.
- Seudonimización para entornos de prueba: sustituir nombres y correos, desplazar fechas, alterar importes — con la advertencia de que una seudonimización mal hecha es reversible (un "hash del email" se revierte probando correos).
- Retención: los datos personales no se guardan para siempre. Hay que decidir cuánto y borrarlos o anonimizarlos después (05-04).
⚠️ Antes de exponer un sistema real: encarga una revisión de seguridad a un profesional (revisión de código, configuración y pruebas de intrusión) y contrasta el tratamiento de los datos personales con el responsable de protección de datos o la asesoría jurídica de tu organización. Esta lección te da el vocabulario y las prácticas mínimas; no sustituye ni a una auditoría ni a un análisis de cumplimiento.
- Checklist de seguridad
Consultas. 1. ¿Todos los valores del usuario van como parámetros? Busca concatenaciones y f-strings con SQL dentro. 2. ¿Hay identificadores dinámicos (ORDER BY, nombre de tabla)? ¿Están resueltos con lista blanca o %I? 3. ¿Se escapan % y _ en las búsquedas con LIKE/ILIKE?
Permisos. 4. ¿La aplicación se conecta con un rol que no es superusuario ni propietario? 5. ¿Ese rol tiene solo lo que necesita, sin DELETE ni TRUNCATE gratuitos? 6. ¿Está puesto ALTER DEFAULT PRIVILEGES para las tablas futuras? 7. ¿Se ha revocado CREATE sobre el esquema public a PUBLIC? 8. ¿Las vistas "de seguridad" tienen revocado el permiso sobre la tabla base? 9. ¿Las funciones SECURITY DEFINER fijan search_path y tienen REVOKE ... FROM PUBLIC?
Datos y operación. 10. ¿La conexión usa TLS y están cifradas las copias de seguridad? 11. ¿Los entornos de prueba tienen datos ficticios o seudonimizados? 12. ¿Los mensajes de error de producción son genéricos, con el detalle en el registro? 13. ¿Se registran y revisan los accesos a datos sensibles y los errores de sintaxis repetidos? 14. ¿Hay fecha para la revisión externa antes de salir a producción?
Errores Comunes y Consejos
- Creer que un dato numérico no es inyectable.
WHERE id = " + entradaes inyectable sin usar ni una comilla. Parametriza siempre, no solo los textos. - Usar un ORM y darlo por seguro. En cuanto aparece
raw(),text()ocreateNativeQuery()con una f-string, la protección desaparece (11-05). Y dejar PDO con la emulación de preparadas activada: escapa en el cliente en lugar de usar preparadas reales. - Conceder
SELECTsinUSAGEsobre el esquema.permission denied for schema publiccon elGRANTde la tabla ya hecho: faltaGRANT USAGE ON SCHEMA. Y olvidarALTER DEFAULT PRIVILEGES: todo funciona hasta que una migración añade una tabla y la aplicación deja de verla en producción. - Confundir
PUBLIC(el pseudo-rol "todos") con el esquemapublic. UnREVOKE ... FROM PUBLICafecta a todos los roles, presentes y futuros. - Crear una vista "de seguridad" y dejar el
GRANTsobre la tabla. No protege nada. YSECURITY DEFINERsinsearch_pathfijado: es una escalada de privilegios de manual. - Consejo: busca
execute(seguido de una f-string o de un+en todo el repositorio. Es la revisión de seguridad más barata y encuentra la mayoría de los casos. Y prueba cada campo del formulario con'y con--: si algo se rompe con una comilla, hay concatenación detrás. - Consejo: escribe los
GRANTen una migración, no a mano. Los permisos son parte del esquema y deben poder reconstruirse desde el repositorio (05-06).
Ejercicios
Ejercicio 1
Este endpoint devuelve los pedidos de un cliente:
def pedidos_de(cliente_id, estado, orden):
sql = ("SELECT id, fecha_pedido, estado FROM pedidos "
f"WHERE cliente_id = {cliente_id} AND estado = '{estado}' ORDER BY {orden}")
return db.execute(sql).fetchall()(1) Señala las tres vías de inyección. (2) Reescríbelo correctamente. (3) ¿Por qué la tercera no se arregla igual que las otras dos?
Ejercicio 2
Diseña los permisos para dos perfiles nuevos de TiendaVerde: tv_almacen (Irene, operaria) necesita ver los pedidos pagados y sus líneas, y actualizar el estado del pedido a enviado; tv_soporte (Marc, atención al cliente) necesita ver todo lo de un cliente, incluido su correo, y crear devoluciones. (1) Escribe los GRANT. (2) ¿Cómo impedirías que tv_almacen cambie el estado a cancelado? (3) ¿Qué le falta a tv_soporte para poder insertar en devoluciones?
Ejercicio 3
Un compañero propone: "Damos SELECT sobre todo a los analistas, que son de confianza, y así no molestan pidiendo permisos". Rebate la propuesta con cuatro argumentos concretos referidos a TiendaVerde, y propón una alternativa que resuelva su problema real.
Soluciones
Solución 1
1. Las tres: cliente_id interpolado sin comillas —inyectable con 1 OR 1=1, y la que más se pasa por alto precisamente por "ser un número"—; estado interpolado dentro de comillas, inyectable cerrándolas; y orden, que es un identificador y no puede ser un parámetro.
# 2
ORDENES = {"fecha": "fecha_pedido DESC", "id": "id", "estado": "estado, id"}
def pedidos_de(cliente_id, estado, orden):
col = ORDENES.get(orden, "id") # lista blanca
sql = ("SELECT id, fecha_pedido, estado FROM pedidos "
f"WHERE cliente_id = %s AND estado = %s ORDER BY {col}")
return db.execute(sql, (int(cliente_id), estado)).fetchall()3. Porque un parámetro es siempre un valor, nunca estructura. ORDER BY $1 ordenaría por la constante $1, no por la columna cuyo nombre contiene: el motor ya ha planificado la consulta cuando recibe el valor, y el orden es parte del plan. Por eso los identificadores se resuelven con lista blanca (o con %I si el SQL se construye dentro del servidor), que es una técnica distinta con el mismo objetivo: que la entrada del usuario nunca aporte texto SQL.
Solución 2
-- 1
CREATE ROLE tv_almacen; CREATE ROLE tv_soporte;
GRANT CONNECT ON DATABASE tiendaverde TO tv_almacen, tv_soporte;
GRANT USAGE ON SCHEMA public TO tv_almacen, tv_soporte;
-- Almacén: ve lo que tiene que preparar y solo puede tocar la columna estado
GRANT SELECT ON pedidos, lineas_pedido, productos TO tv_almacen;
GRANT UPDATE (estado) ON pedidos TO tv_almacen;
-- Soporte: ficha completa del cliente y alta de devoluciones
GRANT SELECT ON clientes, pedidos, lineas_pedido, productos, resenas TO tv_soporte;
GRANT SELECT, INSERT ON devoluciones TO tv_soporte;
GRANT USAGE ON SEQUENCE devoluciones_id_seq TO tv_soporte;2. Un GRANT UPDATE (estado) deja cambiar la columna, pero no controla a qué valor. Eso es una regla de negocio y va en la base: un trigger BEFORE UPDATE (10-05) que rechace transiciones no permitidas, o un procedimiento sp_marcar_enviado al que se concede EXECUTE mientras se retira el UPDATE directo — la segunda opción es más limpia, porque expone la operación y no la columna. 3. Le falta el permiso sobre la secuencia —devoluciones_id_seq—, sin el cual un INSERT que deja generar el id falla con permission denied for sequence. Es el olvido más habitual al conceder INSERT, y por eso en el apartado 8 aparece un GRANT USAGE ON ALL SEQUENCES.
Solución 3
Los cuatro argumentos. (a) clientes.email y empleados.salario no son de confianza ni de desconfianza: son datos personales cuyo acceso debe estar limitado a quien los necesite para su trabajo, y un analista de ventas no los necesita. (b) La confianza no protege del accidente: una exportación a CSV, un portátil perdido o un panel compartido convierten un SELECT legítimo en una fuga. (c) La confianza no protege de la cuenta comprometida: si roban las credenciales de Daniel, el atacante hereda exactamente lo que Daniel tenía. (d) Sin ALTER DEFAULT PRIVILEGES y con "acceso a todo", cada tabla nueva —incluida una futura nominas— queda expuesta por omisión, que es justo lo contrario de lo que debe pasar.
La alternativa, que además resuelve su problema real (dejar de molestar pidiendo permisos): darles tv_lectura sobre las vistas de análisis, no sobre las tablas. Una capa de vistas v_* que excluya las columnas sensibles, con ALTER DEFAULT PRIVILEGES para que las vistas nuevas se concedan solas, y un SECURITY DEFINER para los pocos agregados que necesiten datos restringidos —como fn_salario_medio—. El analista gana autonomía y la exposición es mucho menor.
Conclusión
Esta lección cierra la mitad menos visible del curso y la que más caro se paga si falta:
- La inyección SQL ocurre porque el código y los datos viajan en la misma cadena. Con la entrada
' OR '1'='1, un buscador concatenado pasa de devolver 2 productos a devolver los 19 activos; con--de por medio, los 20, incluido el descatalogado. Y el mismo fallo hace queO'Connorrompa la consulta. La defensa es una sola y es total: consultas parametrizadas. El servidor analiza y planifica antes de ver los valores, así que un valor no puede convertirse en código. Igual en psycopg, JDBC, PDO y node-postgres, y en SQL puro conPREPARE/EXECUTE. - No son defensas: escapar a mano, las listas negras, ocultar los errores, validar en el navegador o "usar un ORM". Y los identificadores no se parametrizan: van con lista blanca, o con
format('%I')/quote_identdentro del servidor. Nunca%s. - Permisos: en PostgreSQL todo son roles;
GRANT/REVOKEsobre base, esquema, tabla, columna, secuencia y función;USAGEsobre el esquema es imprescindible yALTER DEFAULT PRIVILEGESes lo que hace que los objetos futuros hereden.PUBLIC(todos los roles) no es el esquemapublic, y una vista solo protege si se revoca el permiso sobre la tabla. - El diseño de TiendaVerde:
tv_lecturapara analistas sinemailniempleados,tv_appsinDELETEni DDL,tv_adminsolo para migraciones. Y la regla de oro: la aplicación nunca se conecta como superusuario ni como propietario. - RLS filtra por fila —el comercial 4 ve sus 4 pedidos y ninguno más— a cambio de una condición en todas las consultas y de una depuración más difícil;
SECURITY DEFINERpresta privilegios y exigeSET search_pathyREVOKE EXECUTE FROM PUBLIC. Y los datos personales: identificar qué columnas lo son, TLS en tránsito, cifrado en reposo y de las copias, seudonimización en pruebas, retención — con la revisión por un profesional de seguridad y por el responsable legal antes de exponer nada real.
Con esto el sistema está protegido y es mantenible. Toca sacarle valor. En la lección siguiente, SQL para análisis de datos, verás el oficio del analista: el flujo que va de la pregunta de negocio a la métrica bien definida —y por qué la mitad de los errores de análisis son de definición y no de SQL: ¿"ventas" incluye los portes?, ¿y el pedido cancelado?—; las métricas fundamentales de TiendaVerde calculadas una a una; el análisis temporal con acumulados y medias móviles; la segmentación y el Pareto de clientes y productos; las cohortes con su advertencia honesta sobre el tamaño de muestra; la presentación con CASE y con crosstab, cerrando la promesa de 06-05; y dónde encaja SQL frente a Python y a las herramientas de BI.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
