Aquesta lliçó tanca les promeses que el curs fa des del mòdul 5. Tracta dues coses que semblen diferents i són la mateixa: qui decideix què s'executa i qui pot veure què. La primera meitat és la injecció SQL, la fallada de seguretat més vella i més cara de les aplicacions amb base de dades, explicada des del costat que importa: com s'evita. La segona és el sistema de permisos i rols de PostgreSQL, inclosa la seguretat a nivell de fila, amb un disseny concret per a BotigaVerda.

Un avís des del principi: això és una lliçó defensiva. Els exemples d'atac que veuràs són mínims i inofensius —una condició que retorna de més— i són aquí només perquè entenguis per què falla l'enfocament vulnerable. El pes de la lliçó és a les defenses. I una advertència que va de debò: abans d'exposar a Internet un sistema amb dades reals, encarrega una revisió a un professional de seguretat i consulta el tractament de les dades personals amb el responsable legal o de protecció de dades de la teva organització. El que segueix és el mínim que has de saber, no un substitut d'aquesta revisió.

Contingut

  1. Què és una injecció SQL
  2. Per què passa: dades i codi a la mateixa cadena
  3. La defensa: consultes parametritzades
  4. El que no és una defensa suficient
  5. El cas especial: identificadors dinàmics
  6. Defensa en profunditat
  7. Permisos i rols a PostgreSQL
  8. Un disseny de rols per a BotigaVerda
  9. Row Level Security
  10. SECURITY DEFINER i el search_path
  11. Dades personals
  12. Checklist de seguretat
  13. Errors habituals i consells
  14. Exercicis
  15. Conclusió

  1. Què és una injecció SQL

El cercador de la botiga rep un text i busca productes. Escrit de la pitjor manera possible —concatenant— queda així:

# ⚠️ VULNERABLE. No escriguis mai això.
terme = request.args["q"]
sql = "SELECT id, nom, preu FROM productes WHERE actiu AND nom ILIKE '%" + terme + "%'"
cursor.execute(sql)

Amb l'entrada normal oli, el que arriba al servidor és SELECT id, nom, preu FROM productes WHERE actiu AND nom ILIKE '%oli%';, que és correcte i retorna l'esperat:

id nom preu
1 Oli d'oliva verge extra 500 ml 12.50
8 Oli corporal d'ametlles 200 ml 14.25

Dos productes. Ara, l'entrada ' OR '1'='1 — un text perfectament escrivible en qualsevol caixa de cerca. En concatenar-lo, la consulta que s'executa és:

SELECT id, nom, preu FROM productes WHERE actiu AND nom ILIKE '%' OR '1'='1%';

Retorna 19 files: el catàleg actiu sencer. La cometa de l'usuari va tancar la cadena que el programador havia obert, i el que venia al darrere va deixar de ser text de cerca per convertir-se en estructura de la consulta: un OR que ningú no va escriure. Amb la variant ' OR '1'='1' --, el doble guió comenta la resta de la línia, inclòs el %' pendent:

SELECT id, nom, preu FROM productes WHERE actiu AND nom ILIKE '%' OR '1'='1' --%';

20 files. Una més: hi apareixen també les càpsules d'espirulina, el producte descatalogat (actiu = FALSE) que la botiga no hauria de mostrar mai. És un exemple diminut i sense dany, i precisament per això és útil: l'atacant no ha fet res estrany; ha escrit text en una caixa de text, i ha aconseguit que el filtre de negoci deixi d'aplicar-se. Si en lloc d'un catàleg públic la consulta hagués estat la de "les meves comandes" o la d'"empleats", el resultat hauria estat el mateix mecanisme sobre dades que sí que importen.

I una conseqüència menys òbvia però igual de greu: l'entrada O'Connor —un cognom perfectament normal— trenca la consulta amb un error de sintaxi. La mateixa esquerda que permet l'atac fa que el programa falli amb dades legítimes. Un codi vulnerable a injecció és també un codi que no funciona bé.

  1. Per què passa: dades i codi a la mateixa cadena

La causa cap en una frase, i convé aprendre-se-la:

La injecció SQL passa perquè el codi i les dades viatgen barrejats a la mateixa cadena de text, i el motor no té manera de saber quina part vas escriure tu i quina va escriure l'usuari.

El servidor rep un text i l'analitza sencer. Per a ell, OR '1'='1' és llenguatge SQL igual que el SELECT que el precedeix: no hi ha cap marca que digui "això venia d'un formulari". Tot el que se'n deriva —afegir condicions, comentar la resta, encadenar sentències si el connector ho permet— és conseqüència d'aquesta barreja. D'aquí surt l'única defensa que funciona de debò: separar el codi de les dades, i que sigui el motor qui els ajunti sabent quin és quin.

flowchart LR
    A["plantilla SQL"] --> C["⚠️ una sola cadena"] --> D["l'analitzador veu UN text:<br/>no distingeix codi de dada"]
    B["entrada de l'usuari"] --> C
    E["✅ SQL amb marcadors $1, ?"] --> G["el motor analitza i<br/>planifica <b>primer</b>"] --> H["i només després rep els<br/>valors com a <b>dades tipades</b>"]
    F["entrada de l'usuari"] --> H

  1. La defensa: consultes parametritzades

Una consulta parametritzada (o sentència preparada) envia al servidor dues coses separades: el text amb marcadors, i els valors. El servidor analitza i planifica abans de veure els valors; quan arriben, ja no hi ha res a analitzar i un valor no es pot convertir en codi passi el que passi. Ni tan sols cal escapar cometes: el valor no s'insereix al text. El mateix cercador, ben escrit, en quatre entorns:

# Python — psycopg 3
cur.execute("SELECT id, nom, preu FROM productes WHERE actiu AND nom ILIKE %s",
            ('%' + terme + '%',))            # el comodí va al VALOR, no al SQL
// Java — JDBC
PreparedStatement ps = conn.prepareStatement(
    "SELECT id, nom, preu FROM productes WHERE actiu AND nom ILIKE ?");
ps.setString(1, "%" + terme + "%");
ResultSet rs = ps.executeQuery();
// PHP — PDO (amb l'emulació de preparades DESACTIVADA)
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$st = $pdo->prepare("SELECT id, nom, preu FROM productes WHERE actiu AND nom ILIKE :q");
$st->execute([':q' => '%' . $terme . '%']);
// Node.js — node-postgres
const { rows } = await client.query(
  'SELECT id, nom, preu FROM productes WHERE actiu AND nom ILIKE $1',
  [`%${terme}%`]);

Amb l'entrada maliciosa ' OR '1'='1, les quatre versions retornen 0 files: busquen, literalment, productes el nom dels quals contingui el text ' OR '1'='1. Que no n'existeixi cap és exactament el correcte. L'atac deixa de ser un atac i passa a ser una cerca sense resultats.

I el mecanisme també existeix en SQL pur, útil per veure'l sense llenguatge pel mig:

PREPARE buscar_producte (text) AS
    SELECT id, nom, preu FROM productes WHERE actiu AND nom ILIKE $1;
EXECUTE buscar_producte('%oli%');              -- 2 files
EXECUTE buscar_producte('%'' OR ''1''=''1%');  -- 0 files: és només text
DEALLOCATE buscar_producte;

Dos matisos pràctics. El comodí % va dins del valor, mai al text SQL: ILIKE '%' || $1 || '%' també funciona, però llavors convé escapar els % i _ que porti l'usuari, o una cerca de 100% es convertirà en un comodí. I si vols filtrar per una llista de valors, no muntis la llista concatenant: fes servir WHERE id = ANY($1) passant-hi un array, que és el que suporten tots els connectors moderns.

Nota de dialecte. El marcador canvia segons el connector: $1, $2 a PostgreSQL natiu i node-postgres, %s a psycopg, ? a JDBC, PDO i molts altres, :nom per a paràmetres amb nom a PDO, SQLAlchemy o JDBI. El que no canvia és la garantia: mentre el valor viatgi pel canal de paràmetres, no es pot convertir en codi. Compte amb PDO: per omissió emula les preparades escapant al client; desactiva-ho amb ATTR_EMULATE_PREPARES => false per fer servir les de debò.

  1. El que no és una defensa suficient

Falsa defensa Per què falla
Escapar cometes a mà Has d'encertar amb la codificació, els comodins, els comentaris i cada cas límit del dialecte — cada vegada, a cada consulta i per sempre. Un descuit n'hi ha prou. I no protegeix gens on no hi ha cometes: un paràmetre numèric concatenat (WHERE id = + entrada) és injectable sense fer-ne servir ni una
Llistes negres de paraules (DROP, UNION, --) Rebutgen cerques legítimes ("unió europea", "drop d'aigua") i no cobreixen el que no vas imaginar. Filtrar pel que està prohibit sempre perd davant de filtrar pel que està permès
Amagar els missatges d'error És bona idea, però com a capa addicional: redueix la informació que es filtra, no tanca el forat. La vulnerabilitat continua sent-hi, només que a cegues
Validar al navegador / "és una intranet" Qualsevol pot cridar l'API sense passar per la teva pàgina; i la majoria dels incidents vénen de dins o d'un compte compromès
Un ORM, sense més Protegeix el 95 % del que fa per tu, i deixa de protegir-te tan bon punt fas servir el seu raw() / text() / createNativeQuery() concatenant (11-05)

La llista deixa una conclusió: no hi ha graus. O el valor viatja pel canal de paràmetres, o no està protegit.

  1. El cas especial: identificadors dinàmics

Hi ha un cas que les consultes parametritzades no resolen, i és per on es cola la injecció en aplicacions per la resta correctes: un paràmetre no pot ser el nom d'una taula, d'una columna, ni el sentit d'un ORDER BY.

# ⚠️ VULNERABLE: la columna d'ordenació ve de la URL (?ordre=preu&dir=desc)
sql = f"SELECT id, nom, preu FROM productes ORDER BY {ordre} {direccio}"

# ✅ CORRECTA: llista blanca. L'entrada de l'usuari tria una CLAU, no aporta el SQL.
COLUMNES = {"nom": "p.nom", "preu": "p.preu", "data": "p.data_alta"}
DIRECCIO = {"asc": "ASC", "desc": "DESC"}
col = COLUMNES.get(ordre, "p.id")           # valor per defecte segur si no és a la llista
dir_ = DIRECCIO.get(direccio, "ASC")
sql = f"SELECT p.id, p.nom, p.preu FROM productes AS p ORDER BY {col} {dir_}"

La idea clau: l'usuari no aporta text, tria una opció d'un conjunt que tu controles. El que es concatena mai no surt de l'entrada; surt del teu diccionari. I com a efecte secundari, el valor per defecte converteix un paràmetre brossa en un ordre raonable en lloc d'un error.

Quan el SQL dinàmic es construeix dins de PostgreSQL —en una funció PL/pgSQL (10-04)— l'eina és format() amb els seus marcadors de tipus:

CREATE OR REPLACE FUNCTION fn_comptar_files(p_taula text) RETURNS bigint AS $$
DECLARE v_total bigint;
BEGIN
    -- %I = IDENTIFICADOR (entrecometat si cal) · %L = LITERAL · %s = text cru (⚠️)
    EXECUTE format('SELECT COUNT(*) FROM %I', p_taula) INTO v_total;
    RETURN v_total;
END;
$$ LANGUAGE plpgsql;

SELECT fn_comptar_files('linies_comanda');
fn_comptar_files
47

%I aplica quote_ident: entrecometa l'identificador i neutralitza qualsevol cosa estranya que porti. No facis servir mai %s amb entrada de l'usuari; %s és concatenació amb un altre nom. I fins i tot amb %I, el correcte és validar abans contra la llista de taules permeses o contra information_schema: %I impedeix la injecció, però no impedeix que algú compti les files d'una taula que no li correspon.

  1. Defensa en profunditat

Cap capa no basta tota sola; la bona notícia és que se sumen.

Capa Què fa A la pràctica
Parametritzar Tanca la injecció Obligatori, sense excepcions
Validar l'entrada Rebutja l'absurd abans de tocar la base Tipus, longituds, rangs, format, llistes blanques. id és un enter: converteix-lo i falla si no ho és
Privilegi mínim Limita el dany si alguna cosa s'escapa El web no es connecta com a superusuari (apartat 8)
Errors discrets No regala l'esquema A l'usuari, "no s'ha pogut completar l'operació" + identificador d'incidència; el detall, al registre del servidor
Registre i alertes Permet detectar-ho Registrar les fallades de sintaxi repetides: són la signatura d'algú provant
Revisió i proves Ho troba abans que altres Buscar concatenacions a la revisió de codi; anàlisi estàtica; proves amb ' i -- a cada camp

Sobre els missatges d'error: en producció, l'error complet de PostgreSQL li diu a l'usuari el nom de les taules, de les columnes i de les restriccions. Registra el detall al servidor, retorna un text genèric i un identificador que permeti a suport trobar la traça; en desenvolupament, al revés.

  1. Permisos i rols a PostgreSQL

Aquí es tanquen les promeses de 05-04, 09-05, 10-01 i 10-04. El model és més simple del que sembla, amb una idea central:

A PostgreSQL no hi ha "usuaris" i "grups": només hi ha rols. Un rol amb LOGIN es comporta com un usuari; un rol sense LOGIN al qual es concedeixen altres rols es comporta com un grup. És el mateix objecte.

CREATE ROLE bv_lectura;                                   -- grup (sense LOGIN)
CREATE ROLE ana LOGIN PASSWORD 'unaclaullarga';           -- usuari
GRANT bv_lectura TO ana;                                  -- ana hereta els permisos del grup

Els privilegis es concedeixen amb GRANT, es retiren amb REVOKE i s'apliquen a diferents nivells:

Nivell Privilegis habituals Exemple
Base de dades CONNECT, CREATE, TEMPORARY GRANT CONNECT ON DATABASE botigaverda TO bv_lectura;
Esquema USAGE (poder veure els objectes), CREATE GRANT USAGE ON SCHEMA public TO bv_lectura;
Taula / vista SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER GRANT SELECT ON comandes TO bv_lectura;
Columna SELECT (col, …), UPDATE (col, …) GRANT SELECT (id, nom, ciutat, pais) ON clients TO bv_lectura;
Seqüència / funció USAGE, SELECT, UPDATE / EXECUTE Les seqüències calen per a INSERT en taules amb IDENTITY

Dos paranys que enxampen tothom:

  • USAGE sobre l'esquema és imprescindible. Sense ell, GRANT SELECT ON comandes no serveix de res: el rol té permís sobre una taula que no pot assolir. L'error és permission denied for schema public i desconcerta perquè el GRANT de la taula existeix.
  • ALTER DEFAULT PRIVILEGES, o els objectes nous no hereten res. Un GRANT SELECT ON ALL TABLES IN SCHEMA public afecta les taules que existeixen en aquell moment. La taula que creïs demà no hi estarà inclosa, i la fallada apareixerà en producció després del desplegament:
GRANT SELECT ON ALL TABLES IN SCHEMA public TO bv_lectura;                -- les d'avui
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO bv_lectura;  -- i les de demà

Compte: ALTER DEFAULT PRIVILEGES s'aplica als objectes que creï el rol que executa l'ordre. Si les migracions les llança bv_admin, cal executar-ho com a bv_admin (o amb FOR ROLE bv_admin).

PUBLIC i l'esquema public

Són dues coses diferents amb el mateix nom, i confondre-les és el clàssic de la lliçó. PUBLIC és un pseudorol que significa "tots els rols que existeixin i existiran". public és l'esquema per omissió on viuen les nou taules de BotigaVerda.

Històricament, PUBLIC tenia CREATE sobre l'esquema public, de manera que qualsevol usuari hi podia crear objectes. PostgreSQL 15 ho va corregir, però en bases creades abans, o migrades, convé comprovar-ho i endurir-ho:

REVOKE CREATE ON SCHEMA public FROM PUBLIC;      -- que només hi creï qui deu
REVOKE ALL ON DATABASE botigaverda FROM PUBLIC;  -- inclòs CONNECT

I recorda del mòdul 10 que una vista només protegeix si treus el permís sobre la taula: GRANT SELECT ON v_clients_public no serveix de res mentre el rol conservi SELECT ON clients.

  1. Un disseny de rols per a BotigaVerda

Tres rols de grup, tres perfils reals, i ningú que es connecti com a propietari de les taules:

Rol Per a qui Pot No pot
bv_lectura Analistes, quadres de comandament, eines de BI SELECT sobre les taules de negoci i les vistes; sense la columna email de clients Escriure res; veure salaris; veure correus
bv_app L'aplicació web SELECT, INSERT, UPDATE sobre l'operatiu; EXECUTE de sp_confirmar_comanda DELETE, DDL, tocar empleats
bv_admin Migracions i desplegaments Tot el DDL sobre l'esquema És el propietari: no es fa servir des de l'aplicació
REVOKE ALL ON DATABASE botigaverda FROM PUBLIC;
GRANT  CONNECT ON DATABASE botigaverda TO bv_lectura, bv_app, bv_admin;
GRANT  USAGE   ON SCHEMA public        TO bv_lectura, bv_app;

-- bv_lectura: només lectura; a clients només les columnes no sensibles.
-- empleats NO es concedeix en absolut: conté salaris.
GRANT SELECT ON categories, proveidors, productes, comandes, linies_comanda, ressenyes, devolucions
      TO bv_lectura;
GRANT SELECT (id, nom, cognoms, ciutat, pais, data_registre) ON clients TO bv_lectura;

-- bv_app: el que necessita el web, ni un privilegi més.
-- Sense DELETE: el web marca com a cancel·lat, no esborra (esborrat lògic, 05-04).
GRANT SELECT                 ON categories, proveidors, productes, empleats TO bv_app;
GRANT SELECT, INSERT, UPDATE ON clients, comandes, linies_comanda, ressenyes TO bv_app;
GRANT USAGE   ON ALL SEQUENCES IN SCHEMA public TO bv_app;   -- imprescindible per als IDENTITY
GRANT EXECUTE ON PROCEDURE sp_confirmar_comanda(int, int, int[], int[]) TO bv_app;

-- Els objectes que creï bv_admin demà heretaran aquests permisos
ALTER DEFAULT PRIVILEGES FOR ROLE bv_admin IN SCHEMA public GRANT SELECT ON TABLES TO bv_lectura;
ALTER DEFAULT PRIVILEGES FOR ROLE bv_admin IN SCHEMA public
      GRANT SELECT, INSERT, UPDATE ON TABLES TO bv_app;

-- Persones i serveis, amb LOGIN, heretant del grup
CREATE ROLE daniel   LOGIN PASSWORD '...'; GRANT bv_lectura TO daniel;   -- analista de dades
CREATE ROLE web_prod LOGIN PASSWORD '...'; GRANT bv_app     TO web_prod;

Comprovar què té cada rol és tan important com concedir-ho:

SELECT grantee, table_name, string_agg(privilege_type, ', ' ORDER BY privilege_type) AS privilegis
FROM   information_schema.role_table_grants
WHERE  grantee IN ('bv_lectura','bv_app') AND table_name = 'comandes'
GROUP  BY grantee, table_name ORDER BY grantee;
grantee table_name privilegis
bv_app comandes INSERT, SELECT, UPDATE
bv_lectura comandes SELECT

A psql, \dp comandes dona la mateixa informació de manera compacta. La regla d'or: l'aplicació no es connecta mai com a superusuari ni com a propietari de les taules. Si web_prod és propietari, tots els REVOKE del món són decoratius: el propietari s'ho pot tornar a concedir tot.

  1. Row Level Security

Els permisos anteriors arriben fins a la taula i la columna. Row Level Security (RLS) arriba fins a la fila: permet que dos usuaris executin SELECT * FROM comandes i obtinguin resultats diferents. El cas clàssic: que cada comercial vegi només les seves comandes.

ALTER TABLE comandes ENABLE ROW LEVEL SECURITY;
CREATE POLICY pol_comandes_comercial ON comandes FOR SELECT TO bv_comercial
    USING (empleat_id = current_setting('app.empleat_id', true)::int);

Amb SET app.empleat_id = '4' a l'inici de la sessió, el comercial Óscar (empleat 4) veu 4 comandes —les números 2, 6, 10 i 16— i cap més, encara que escrigui SELECT * FROM comandes sense WHERE. Les deu comandes web, amb empleat_id NULL, no les veu ningú amb aquesta política, perquè NULL = 4 no és cert: si les han de veure tots, la condició seria empleat_id = ... OR empleat_id IS NULL, i aquesta decisió cal prendre-la explícitament.

Filtrar a l'aplicació RLS
On viu la regla A cada consulta de cada pantalla A la taula, una sola vegada
Si algú oblida el WHERE Fuita de dades No passa res
Accés des de psql o un informe Sense protecció Protegit
Cost Cap La condició s'afegeix a totes les consultes: pot impedir plans bons
Depuració Senzilla "Per què no veig la meva fila?" és una pregunta difícil

Tres avisos. El propietari de la taula i els superusuaris se salten RLS per omissió (cal FORCE ROW LEVEL SECURITY). Les polítiques s'escriuen per operació —FOR SELECT, FOR INSERT amb WITH CHECK, FOR UPDATE, FOR ALL— i si no defineixes la d'INSERT, no s'hi pot inserir res. I el cost de rendiment és real: la condició entra en totes les consultes, així que la columna de la política ha d'estar indexada. RLS brilla en multiinquilí (empresa_id); per a tres pantalles i un rol, sol bastar una vista.

  1. SECURITY DEFINER i el search_path

Tanca 10-04. Per omissió una funció s'executa amb els permisos de qui la crida (SECURITY INVOKER). Amb SECURITY DEFINER s'executa amb els de qui la va crear, cosa que permet donar accés controlat a dades que l'usuari no pot llegir: per exemple, que un analista obtingui la mitjana salarial sense poder llegir empleats.

CREATE OR REPLACE FUNCTION fn_salari_mitja() RETURNS numeric
LANGUAGE sql SECURITY DEFINER
SET search_path = public, pg_temp        -- ⬅️ IMPRESCINDIBLE
AS $$ SELECT ROUND(AVG(salari), 2) FROM empleats; $$;

REVOKE EXECUTE ON FUNCTION fn_salari_mitja() FROM PUBLIC;
GRANT  EXECUTE ON FUNCTION fn_salari_mitja() TO bv_lectura;
SELECT fn_salari_mitja();
fn_salari_mitja
35037.50

La mitjana salarial canònica del curs, 35.037,50 €, servida sense donar accés a la taula. I ara el risc, que és seriós: una funció SECURITY DEFINER corre amb privilegis aliens, així que si l'atacant controla quins objectes resol a dins, executa codi amb aquells privilegis. El vector és el search_path: si la funció diu FROM empleats sense qualificar i qui la crida ha posat al davant un esquema seu amb una taula empleats pròpia, la funció llegirà la de l'atacant.

Les quatre regles, i no són opcionals: (1) fixa sempre SET search_path = public, pg_temp en tota funció SECURITY DEFINER (o qualifica cada objecte: public.empleats); (2) REVOKE EXECUTE ... FROM PUBLIC i concedeix només a qui pertoqui, perquè per omissió EXECUTE es concedeix a PUBLIC; (3) fes la funció tan petita com sigui possible i sense SQL dinàmic a dins —i si n'hi ha, format('%I'/'%L') i llista blanca—; (4) fes-la servir només quan calgui: SECURITY INVOKER és el valor per omissió per una bona raó.

  1. Dades personals

BotigaVerda és fictícia, però el seu esquema té exactament les dades que en un sistema real estan regulades:

Columna Què és Tractament
clients.nom, cognoms Identifiquen una persona Accés restringit; pseudonimitzar en proves
clients.email Identificador directe Fora de bv_lectura; mai en un CSV que circuli
clients.ciutat, pais Localització aproximada Sol bastar per a anàlisi; el detall, no
empleats.salari Dada laboral sensible Només per a qui ho necessiti; SECURITY DEFINER per a agregats
comandes, linies_comanda Perfil de consum d'una persona identificada Agregats sí; detall nominal, restringit
ressenyes.comentari Text lliure: pot contenir qualsevol cosa Revisar abans d'exportar

Les mesures que cal conèixer, sense entrar en matèria jurídica:

  • Xifratge en trànsit: TLS obligatori a la connexió (sslmode=require o superior); sense ell, credencials i dades viatgen en clar. En repòs: xifratge de disc o de volum, i de les còpies de seguretat, que és el que més s'oblida.
  • Privilegi mínim, que és el de l'apartat 8: la majoria dels analistes no necessita veure correus. I registre d'accessos a les dades sensibles, no només dels canvis.
  • Pseudonimització per a entorns de prova: substituir noms i correus, desplaçar dates, alterar imports — amb l'advertiment que una pseudonimització mal feta és reversible (un "hash de l'email" es reverteix provant correus).
  • Retenció: les dades personals no es desen per sempre. Cal decidir quant de temps i esborrar-les o anonimitzar-les després (05-04).

⚠️ Abans d'exposar un sistema real: encarrega una revisió de seguretat a un professional (revisió de codi, configuració i proves d'intrusió) i contrasta el tractament de les dades personals amb el responsable de protecció de dades o l'assessoria jurídica de la teva organització. Aquesta lliçó et dona el vocabulari i les pràctiques mínimes; no substitueix ni una auditoria ni una anàlisi de compliment.

  1. Checklist de seguretat

Consultes. 1. Tots els valors de l'usuari van com a paràmetres? Busca concatenacions i f-strings amb SQL a dins. 2. Hi ha identificadors dinàmics (ORDER BY, nom de taula)? Estan resolts amb llista blanca o %I? 3. S'escapen % i _ a les cerques amb LIKE/ILIKE?

Permisos. 4. L'aplicació es connecta amb un rol que no és superusuari ni propietari? 5. Aquell rol té només el que necessita, sense DELETE ni TRUNCATE gratuïts? 6. Està posat ALTER DEFAULT PRIVILEGES per a les taules futures? 7. S'ha revocat CREATE sobre l'esquema public a PUBLIC? 8. Les vistes "de seguretat" tenen revocat el permís sobre la taula base? 9. Les funcions SECURITY DEFINER fixen search_path i tenen REVOKE ... FROM PUBLIC?

Dades i operació. 10. La connexió fa servir TLS i estan xifrades les còpies de seguretat? 11. Els entorns de prova tenen dades fictícies o pseudonimitzades? 12. Els missatges d'error de producció són genèrics, amb el detall al registre? 13. Es registren i es revisen els accessos a dades sensibles i els errors de sintaxi repetits? 14. Hi ha data per a la revisió externa abans de sortir a producció?

Errors habituals i consells

  • Creure que una dada numèrica no és injectable. WHERE id = " + entrada és injectable sense fer servir ni una cometa. Parametritza sempre, no només els textos.
  • Fer servir un ORM i donar-lo per segur. Tan bon punt apareix raw(), text() o createNativeQuery() amb una f-string, la protecció desapareix (11-05). I deixar PDO amb l'emulació de preparades activada: escapa al client en lloc de fer servir preparades reals.
  • Concedir SELECT sense USAGE sobre l'esquema. permission denied for schema public amb el GRANT de la taula ja fet: falta GRANT USAGE ON SCHEMA. I oblidar ALTER DEFAULT PRIVILEGES: tot funciona fins que una migració afegeix una taula i l'aplicació deixa de veure-la en producció.
  • Confondre PUBLIC (el pseudorol "tots") amb l'esquema public. Un REVOKE ... FROM PUBLIC afecta tots els rols, presents i futurs.
  • Crear una vista "de seguretat" i deixar el GRANT sobre la taula. No protegeix res. I SECURITY DEFINER sense search_path fixat: és una escalada de privilegis de manual.
  • Consell: busca execute( seguit d'una f-string o d'un + a tot el repositori. És la revisió de seguretat més barata i troba la majoria dels casos. I prova cada camp del formulari amb ' i amb --: si alguna cosa es trenca amb una cometa, hi ha concatenació al darrere.
  • Consell: escriu els GRANT en una migració, no a mà. Els permisos són part de l'esquema i han de poder reconstruir-se des del repositori (05-06).

Exercicis

Exercici 1

Aquest endpoint retorna les comandes d'un client:

def comandes_de(client_id, estat, ordre):
    sql = ("SELECT id, data_comanda, estat FROM comandes "
           f"WHERE client_id = {client_id} AND estat = '{estat}' ORDER BY {ordre}")
    return db.execute(sql).fetchall()

(1) Assenyala les tres vies d'injecció. (2) Reescriu-lo correctament. (3) Per què la tercera no s'arregla igual que les altres dues?

Exercici 2

Dissenya els permisos per a dos perfils nous de BotigaVerda: bv_magatzem (Irene, operària) necessita veure les comandes pagades i les seves línies, i actualitzar l'estat de la comanda a enviat; bv_suport (Marc, atenció al client) necessita veure tot el d'un client, inclòs el seu correu, i crear devolucions. (1) Escriu els GRANT. (2) Com impediries que bv_magatzem canviï l'estat a cancellat? (3) Què li falta a bv_suport per poder inserir a devolucions?

Exercici 3

Un company proposa: "Donem SELECT sobre tot als analistes, que són de confiança, i així no molesten demanant permisos". Rebat la proposta amb quatre arguments concrets referits a BotigaVerda, i proposa una alternativa que resolgui el seu problema real.

Solucions

Solució 1

1. Les tres: client_id interpolat sense cometes —injectable amb 1 OR 1=1, i la que més es passa per alt precisament per "ser un número"—; estat interpolat dins de cometes, injectable tancant-les; i ordre, que és un identificador i no pot ser un paràmetre.

# 2
ORDRES = {"data": "data_comanda DESC", "id": "id", "estat": "estat, id"}
def comandes_de(client_id, estat, ordre):
    col = ORDRES.get(ordre, "id")                        # llista blanca
    sql = ("SELECT id, data_comanda, estat FROM comandes "
           f"WHERE client_id = %s AND estat = %s ORDER BY {col}")
    return db.execute(sql, (int(client_id), estat)).fetchall()

3. Perquè un paràmetre és sempre un valor, mai estructura. ORDER BY $1 ordenaria per la constant $1, no per la columna el nom de la qual conté: el motor ja ha planificat la consulta quan rep el valor, i l'ordre és part del pla. Per això els identificadors es resolen amb llista blanca (o amb %I si el SQL es construeix dins del servidor), que és una tècnica diferent amb el mateix objectiu: que l'entrada de l'usuari no aporti mai text SQL.

Solució 2

-- 1
CREATE ROLE bv_magatzem;  CREATE ROLE bv_suport;
GRANT CONNECT ON DATABASE botigaverda TO bv_magatzem, bv_suport;
GRANT USAGE   ON SCHEMA public        TO bv_magatzem, bv_suport;
-- Magatzem: veu el que ha de preparar i només pot tocar la columna estat
GRANT SELECT         ON comandes, linies_comanda, productes TO bv_magatzem;
GRANT UPDATE (estat) ON comandes                            TO bv_magatzem;
-- Suport: fitxa completa del client i alta de devolucions
GRANT SELECT         ON clients, comandes, linies_comanda, productes, ressenyes TO bv_suport;
GRANT SELECT, INSERT ON devolucions TO bv_suport;
GRANT USAGE ON SEQUENCE devolucions_id_seq TO bv_suport;

2. Un GRANT UPDATE (estat) deixa canviar la columna, però no controla a quin valor. Això és una regla de negoci i va a la base: un disparador BEFORE UPDATE (10-05) que rebutgi transicions no permeses, o un procediment sp_marcar_enviat al qual es concedeix EXECUTE mentre es retira l'UPDATE directe — la segona opció és més neta, perquè exposa l'operació i no la columna. 3. Li falta el permís sobre la seqüènciadevolucions_id_seq—, sense el qual un INSERT que deixa generar l'id falla amb permission denied for sequence. És l'oblit més habitual en concedir INSERT, i per això a l'apartat 8 hi apareix un GRANT USAGE ON ALL SEQUENCES.

Solució 3

Els quatre arguments. (a) clients.email i empleats.salari no són de confiança ni de desconfiança: són dades personals l'accés a les quals ha d'estar limitat a qui les necessiti per a la seva feina, i un analista de vendes no les necessita. (b) La confiança no protegeix de l'accident: una exportació a CSV, un portàtil perdut o un panell compartit converteixen un SELECT legítim en una fuita. (c) La confiança no protegeix del compte compromès: si roben les credencials del Daniel, l'atacant hereta exactament el que el Daniel tenia. (d) Sense ALTER DEFAULT PRIVILEGES i amb "accés a tot", cada taula nova —inclosa una futura nomines— queda exposada per omissió, que és just el contrari del que ha de passar.

L'alternativa, que a més resol el seu problema real (deixar de molestar demanant permisos): donar-los bv_lectura sobre les vistes d'anàlisi, no sobre les taules. Una capa de vistes v_* que exclogui les columnes sensibles, amb ALTER DEFAULT PRIVILEGES perquè les vistes noves es concedeixin soles, i un SECURITY DEFINER per als pocs agregats que necessitin dades restringides —com fn_salari_mitja—. L'analista guanya autonomia i l'exposició és molt menor.

Conclusió

Aquesta lliçó tanca la meitat menys visible del curs i la que més car es paga si falta:

  • La injecció SQL passa perquè el codi i les dades viatgen a la mateixa cadena. Amb l'entrada ' OR '1'='1, un cercador concatenat passa de retornar 2 productes a retornar-ne els 19 actius; amb -- pel mig, els 20, inclòs el descatalogat. I la mateixa fallada fa que O'Connor trenqui la consulta. La defensa és una de sola i és total: consultes parametritzades. El servidor analitza i planifica abans de veure els valors, així que un valor no es pot convertir en codi. Igual a psycopg, JDBC, PDO i node-postgres, i en SQL pur amb PREPARE/EXECUTE.
  • No són defenses: escapar a mà, les llistes negres, amagar els errors, validar al navegador o "fer servir un ORM". I els identificadors no es parametritzen: van amb llista blanca, o amb format('%I') / quote_ident dins del servidor. Mai %s.
  • Permisos: a PostgreSQL tot són rols; GRANT/REVOKE sobre base, esquema, taula, columna, seqüència i funció; USAGE sobre l'esquema és imprescindible i ALTER DEFAULT PRIVILEGES és el que fa que els objectes futurs heretin. PUBLIC (tots els rols) no és l'esquema public, i una vista només protegeix si es revoca el permís sobre la taula.
  • El disseny de BotigaVerda: bv_lectura per a analistes sense email ni empleats, bv_app sense DELETE ni DDL, bv_admin només per a migracions. I la regla d'or: l'aplicació no es connecta mai com a superusuari ni com a propietari.
  • RLS filtra per fila —el comercial 4 veu les seves 4 comandes i cap més— a canvi d'una condició en totes les consultes i d'una depuració més difícil; SECURITY DEFINER presta privilegis i exigeix SET search_path i REVOKE EXECUTE FROM PUBLIC. I les dades personals: identificar quines columnes ho són, TLS en trànsit, xifratge en repòs i de les còpies, pseudonimització en proves, retenció — amb la revisió per part d'un professional de seguretat i pel responsable legal abans d'exposar res real.

Amb això el sistema està protegit i és mantenible. Toca treure'n valor. A la lliçó següent, SQL per a l'anàlisi de dades, veuràs l'ofici de l'analista: el flux que va de la pregunta de negoci a la mètrica ben definida —i per què la meitat dels errors d'anàlisi són de definició i no de SQL: "vendes" inclou els ports?, i la comanda cancel·lada?—; les mètriques fonamentals de BotigaVerda calculades una a una; l'anàlisi temporal amb acumulats i mitjanes mòbils; la segmentació i el Pareto de clients i productes; les cohorts amb el seu advertiment honest sobre la mida de la mostra; la presentació amb CASE i amb crosstab, tancant la promesa de 06-05; i on encaixa SQL enfront de Python i de les eines de BI.

Curs de SQL

Mòdul 1: Introducció a SQL

Mòdul 2: Consultes bàsiques de SQL

Mòdul 3: Treballar amb múltiples taules

Mòdul 4: Filtratge avançat de dades

Mòdul 5: Manipulació de dades

Mòdul 6: Funcions avançades de SQL

Mòdul 7: Subconsultes i consultes imbricades

Mòdul 8: Índexs i optimització del rendiment

Mòdul 9: Transaccions i concurrència

Mòdul 10: Temes avançats

Mòdul 11: SQL a la pràctica

Mòdul 12: Projecte final

© Copyright 2026. Tots els drets reservats