La lliçó anterior va deixar CicloUrbana amb un forat que arrossegàvem des del principi del mòdul. La Marta té un token vàlid amb el rol ROLE_CIUTADA, així que la seva petició POST /api/v1/lloguers/9/finalitzar travessa la cadena de filtres sense ni un problema: la ruta està permesa a usuaris autenticats i el rol és correcte. Però el lloguer número 9 és d'un altre ciutadà de Ribalta. La Marta acaba de tancar el lloguer d'un desconegut, i l'import se li ha cobrat a ell.
Cap regla d'authorizeHttpRequests no ho pot impedir, perquè la resposta no depèn de la ruta ni del rol: depèn de la dada. Aquesta lliçó baixa la seguretat fins a la capa on viu la lògica de negoci, amb @EnableMethodSecurity, @PreAuthorize i expressions SpEL, i després tanca el mòdul endurint l'API sencera: CORS per entorn, limitació de taxa, HTTPS obligatori, ocultació de la documentació en producció, auditoria d'esdeveniments, revisió de dependències vulnerables i una llista de comprovació abans d'exposar la xarxa de Ribalta a Internet.
Advertiment que es repetirà al final, perquè és el més important del mòdul. Tot el que hem construït en aquestes cinc lliçons és un punt de partida didàctic. Abans d'exposar un servei real a Internet, la configuració de seguretat ha de ser revisada per un professional de seguretat i sotmesa a una auditoria independent. Els secrets no es versionen mai, i cap llista de comprovació no substitueix una prova de penetració feta per algú que no ha escrit el codi.
Contingut
- Per què les regles per URL no basten
@EnableMethodSecurityi les seves anotacions- SpEL a les expressions de seguretat
- Un avaluador de permisos propi:
SeguretatLloguers @PostAuthorizei@PostFilter: el cost de filtrar tard- On posar les anotacions: proxies i autoinvocació
- Enduriment de l'API
- Els riscos OWASP i la seva mitigació a CicloUrbana
- Auditoria d'esdeveniments de seguretat
- Dependències vulnerables
- Llista de comprovació abans de producció
- Errors Comuns i Consells
- Exercicis
- Per què les regles per URL no basten
Les regles de 05-02 són necessàries i són la primera línia de defensa, però tenen tres límits estructurals:
No poden expressar regles que depenen de la dada. «Només els teus propis lloguers» no és una propietat de la ruta /api/v1/lloguers/9/finalitzar: és una relació entre l'usuari autenticat i la fila 9 de la taula. L'URL és idèntic tant si el lloguer és propi com aliè.
La mateixa operació s'assoleix des de diversos llocs. LloguerService.finalitzar el crida avui LloguerController; demà el cridarà una tasca programada (07-03), un consumidor de missatges o un endpoint nou, i cada punt d'entrada és una oportunitat d'oblidar la comprovació. Posar la regla al servei l'aplica una sola vegada i per a tots.
La protecció és lluny de la regla. Qui llegeix LloguerService no veu cap comprovació i l'ha de buscar en un altre fitxer, en un altre paquet, escrita com un patró de ruta. La seguretat declarada al costat del mètode que protegeix és la que es manté actualitzada.
| Seguretat per URL (05-02) | Seguretat de mètode (aquesta lliçó) | |
|---|---|---|
| On es declara | SecurityFilterChain |
Anotació sobre el mètode |
| Quan s'avalua | Abans del DispatcherServlet |
En invocar el mètode, via proxy |
| Què pot consultar | Ruta, verb, rols | Els arguments i el resultat |
| Regles per dada | No | Sí |
| Cost | Molt baix | Baix, però real |
| Paper | Barrera perimetral | Regla fina |
No són alternatives, són capes. La regla per URL descarta aviat el que no ha ni d'arribar; la de mètode aplica el que només es pot decidir amb la dada a la mà:
flowchart LR
C["POST /lloguers/9/finalitzar<br/>Bearer de la Marta"] --> F["AuthorizationFilter<br/>autenticat?"]
F -- "No" --> E1["401"]
F -- "Sí, i la ruta<br/>ho permet" --> P["Proxy de LloguerService<br/>@PreAuthorize"]
P -- "El lloguer 9<br/>no és seu" --> E2["403"]
P -- "És seu, o<br/>OPERARI/ADMIN" --> M["finalitzar(): lògica de negoci"]
@EnableMethodSecurity i les seves anotacions
@EnableMethodSecurity i les seves anotacionsS'activa amb una anotació en una classe de configuració:
@Configuration
@EnableMethodSecurity // prePostEnabled = true per defecte
public class ConfiguracioSeguretatMetode { }A Spring Security 6, @EnableMethodSecurity substitueix l'antic @EnableGlobalMethodSecurity i activa per defecte @PreAuthorize i @PostAuthorize. Els seus atributs habiliten la resta: @EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true) hi afegeix @Secured i @RolesAllowed.
| Anotació | Quan s'avalua | Pot fer servir SpEL | Ús recomanat |
|---|---|---|---|
@PreAuthorize |
Abans d'invocar | Sí | L'opció per defecte a CicloUrbana |
@PostAuthorize |
Després, sobre el resultat | Sí, amb returnObject |
Només si el permís depèn del resultat |
@PreFilter / @PostFilter |
Filtren una col·lecció, abans o després | Sí, amb filterObject |
@PostFilter: evitar, filtra en memòria |
@Secured |
Abans | No: només llista de rols | Codi heretat |
@RolesAllowed |
Abans | No | Igual, però estàndard de Jakarta |
El criteri: fes servir @PreAuthorize tret que tinguis una raó concreta. És l'única que combina avaluació primerenca amb expressions completes; @Secured i @RolesAllowed només admeten una llista de rols, cosa que ja cobreixen les regles per URL; i tot el que s'avalua després implica que el mètode ja s'ha executat, amb el seu cost i els seus efectes secundaris.
- SpEL a les expressions de seguretat
Les expressions s'escriuen en SpEL (Spring Expression Language) i disposen d'un vocabulari propi:
| Expressió | Què avalua |
|---|---|
hasRole('OPERARI') |
Autoritat ROLE_OPERARI, amb la jerarquia de 05-03 aplicada |
hasAnyRole('OPERARI','ADMIN') |
Qualsevol d'ells |
hasAuthority('estacions:escriure') |
Autoritat exacta, sense prefix |
authentication |
L'objecte Authentication complet |
principal |
El principal: el nostre UsuariAutenticat |
isAuthenticated(), isAnonymous() |
Estat d'autenticació |
permitAll, denyAll |
Constants |
#nomParametre |
Un argument del mètode pel seu nom |
returnObject |
El valor retornat (només a @PostAuthorize) |
filterObject |
Cada element d'una col·lecció (als *Filter) |
@beanDeSeguretat.metode(...) |
Crida un bean del context |
Exemples reals del projecte:
@Service
public class BicicletaService {
/** Donar de baixa una bicicleta és manteniment: la jerarquia inclou ADMIN. */
@PreAuthorize("hasRole('OPERARI')")
@Transactional
public void marcarAvariada(String matricula, String motiu) { ... }
/** Consultar el detall: qualsevol usuari identificat. */
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public BicicletaResponse obtenirPerMatricula(String matricula) { ... }
}
@Service
public class UsuariService {
/** Cadascú veu la seva fitxa; un administrador, la de qualsevol. */
@PreAuthorize("#idUsuari == principal.idUsuari or hasRole('ADMIN')")
@Transactional(readOnly = true)
public UsuariResponse obtenir(Long idUsuari) { ... }
}Aquesta última expressió és l'exemple canònic i mereix desgranar-se. #idUsuari es refereix al paràmetre del mètode pel seu nom; principal.idUsuari invoca getIdUsuari() sobre l'UsuariAutenticat de 05-03 —aquí es cobra per segona vegada la decisió de crear un UserDetails propi: amb el User estàndard no hi hauria cap id per comparar—; i or hasRole('ADMIN') hi afegeix l'excepció administrativa.
Compte amb els noms de paràmetre.
#idUsuarinomés funciona si el compilador conserva els noms dels arguments. Elspring-boot-maven-pluginactiva-parametersper defecte, però si el projecte ho perd, l'expressió falla en temps d'execució amb un missatge confús. L'alternativa robusta és@P("idUsuari")sobre el paràmetre, o#p0per posició —menys llegible—.
I un advertiment de fons: SpEL s'avalua en temps d'execució i no el comprova el compilador. Un error tipogràfic a hasRole('OPERRARI') o a principal.idUsuarri no impedeix compilar ni arrencar: falla a la primera crida real, o pitjor, denega sempre. És la raó que aquestes expressions necessitin proves automàtiques, que és exactament el que comença al mòdul 6.
- Un avaluador de permisos propi:
SeguretatLloguers
SeguretatLloguersTornem al problema del principi. La regla és: un ciutadà només pot finalitzar els seus propis lloguers; un operari o un administrador, qualsevol. Es podria intentar a l'anotació:
// ❌ Illegible, no comprovable i amb una consulta amagada dins d'una cadena
@PreAuthorize("hasAnyRole('OPERARI','ADMIN') or "
+ "@lloguerRepositori.findById(#idLloguer).orElse(null)?.usuari?.id "
+ "== principal.idUsuari")Aquesta expressió no es pot provar de manera aïllada, no l'entén qui la llegeix, amaga una consulta a la base de dades dins d'una cadena de text i no compila res del que hi ha a dins. La solució idiomàtica és un bean de seguretat:
package com.ciclourbana.seguretat;
/** Regles de propietat sobre els lloguers. Consultable des de SpEL. */
@Component("seguretatLloguers")
public class SeguretatLloguers {
private final LloguerRepositori lloguerRepositori; // constructor omès
/** L'usuari autenticat és el titular del lloguer indicat? */
@Transactional(readOnly = true)
public boolean esPropietari(Long idLloguer, UsuariAutenticat usuari) {
if (idLloguer == null || usuari == null) return false;
return lloguerRepositori.existsByIdAndUsuariId(idLloguer, usuari.getIdUsuari());
}
}
@Service
public class LloguerService {
@PreAuthorize("hasAnyRole('OPERARI','ADMIN') or "
+ "@seguretatLloguers.esPropietari(#idLloguer, principal)")
@Transactional
public LloguerResponse finalitzar(Long idLloguer, FinalitzarLloguerRequest peticio) {
// La lògica de negoci de 04-07, intacta: no hi ha ni un if de seguretat
}
}Cinc avantatges d'aquesta forma, i són els que la converteixen en la recomanada:
- És llegible. «És operari o administrador, o és el propietari» es llegeix de seguida i es pot ensenyar a l'ajuntament.
- És comprovable.
SeguretatLloguersés un bean normal: es prova amb JUnit i Mockito (06-02, 06-03) sense aixecar el context de seguretat. - És reutilitzable. La mateixa expressió serveix a
obtenirPerId, acancellari a qualsevol mètode futur. - És eficient.
existsByIdAndUsuariIdés una consulta derivada (04-06) que retorna un booleà; no carrega l'entitat ni les seves relacions. - Concentra el canvi. Si demà un ciutadà pot gestionar els lloguers d'un familiar autoritzat, es canvia un mètode Java, no set anotacions.
Fixa't en l'ordre de l'expressió: hasAnyRole va primer a propòsit. SpEL avalua or en curtcircuit, així que per a un operari la consulta a la base de dades ni tan sols s'executa.
I amb això el forat està tancat. La petició de la Marta sobre el lloguer aliè ja no arriba al cos del mètode: AccessDeniedException puja pel proxy, ExceptionTranslationFilter la tradueix i el AccessDeniedHandler de 05-03 retorna un 403 amb format ProblemDetail.
Un matís de disseny. Retornar
403confirma que el lloguer 9 existeix. En un servei on la mera existència sigui informació sensible, la resposta correcta és404: «aquí no hi ha res per a tu». Per a CicloUrbana el403és acceptable i més clar; en una aplicació amb dades delicades, planteja't el404.
@PostAuthorize i @PostFilter: el cost de filtrar tard
@PostAuthorize i @PostFilter: el cost de filtrar tard@PostAuthorize avalua l'expressió després d'executar el mètode, amb accés a returnObject:
@PostAuthorize("returnObject.usuariId == principal.idUsuari or hasRole('ADMIN')")
@Transactional(readOnly = true)
public LloguerResponse obtenirPerId(Long idLloguer) { ... }És còmode quan el permís depèn d'una dada que només es coneix després de consultar, i té dos costos: el mètode ja s'ha executat, amb les seves consultes i el seu temps, i qualsevol efecte secundari ja ha passat. D'aquí una regla dura: no facis servir mai @PostAuthorize en un mètode que escriu. Si fa un càrrec, envia un correu o canvia l'estat d'una bicicleta, denegar l'accés a posteriori no desfà res; amb @Transactional l'excepció sí que provoca rollback de la base de dades (04-07), però no del correu enviat ni de la crida al proveïdor de pagaments.
@PostFilter recorre la col·lecció retornada i n'elimina els elements que no passen l'expressió:
// ❌ Funciona, i és una mala idea
@PostFilter("filterObject.usuariId == principal.idUsuari")
public List<LloguerResponse> llistarTots() { ... }El problema és d'eficiència i d'escala. Si la xarxa de Ribalta té 40.000 lloguers, aquest mètode els porta tots, construeix 40.000 DTO i en descarta 39.987 en memòria. I amb la paginació de 04-05 el resultat és directament incorrecte: es demana la pàgina 0 amb 20 elements, el filtre en descarta 18 i el ciutadà rep una pàgina de 2 amb un total que menteix. La solució correcta és filtrar a la consulta, enllaçant amb 04-06:
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public PaginaResponse<LloguerResponse> llistarMeus(UsuariAutenticat usuari,
Pageable paginacio) {
return PaginaResponse.de(
lloguerRepositori.findByUsuariId(usuari.getIdUsuari(), paginacio)
.map(lloguerMapper::aResposta));
}@PostFilter |
Filtrar a la consulta | |
|---|---|---|
| Files llegides | Totes | Només les de l'usuari |
| Compatible amb paginació | No | Sí |
| Cost amb 40.000 lloguers | Inacceptable | Constant |
| On es veu la regla | A l'anotació | Al repositori |
La regla pràctica: @PostFilter només per a col·leccions petites i acotades —els quatre estats d'una estació, la llista de tarifes— i mai sobre resultats paginats. Per a tota la resta, la seguretat forma part de la consulta.
- On posar les anotacions: proxies i autoinvocació
Les anotacions van al servei, no al controlador, per tres raons: el servei és el punt pel qual passen tots els camins, inclosos els futurs; el controlador s'ocupa del transport HTTP i no de les regles del negoci; i una regla al controlador es duplica tan bon punt apareix un segon punt d'entrada. I funcionen exactament igual que @Transactional (04-07): mitjançant un proxy que avalua l'expressió abans de delegar en l'objecte real. D'aquí, tres conseqüències que ja coneixem:
@Service
public class LloguerService {
@Transactional
public void finalitzarLot(List<Long> ids) {
for (Long id : ids) {
finalitzar(id, peticio); // ❌ crida interna: NO passa pel proxy
} // la comprovació de @PreAuthorize se salta!
}
@PreAuthorize("@seguretatLloguers.esPropietari(#idLloguer, principal)")
public LloguerResponse finalitzar(Long idLloguer, FinalitzarLloguerRequest p) { ... }
}L'autoinvocació esquiva la seguretat, igual que esquiva la transacció. És la mateixa trampa de 04-07 i aquí és pitjor, perquè el símptoma no és una transacció que no s'obre: és una comprovació de permisos que no s'executa. Les sortides són les mateixes: extreure el mètode a un altre bean —l'opció neta—, injectar el proxy d'un mateix amb @Lazy, o replantejar el disseny.
Les altres dues conseqüències del model de proxies: els mètodes private, static i final no es poden interceptar —una anotació sobre un mètode privat s'ignora en silenci, sense cap avís—, i el bean ha de ser gestionat per Spring; un objecte creat amb new no té proxy ni seguretat.
- Enduriment de l'API
La seguretat de l'aplicació no s'acaba en l'autenticació i l'autorització. Aquests són els punts que falten, cadascun amb la seva configuració concreta.
7.1. CORS restrictiu i per entorn
ConfiguracioCors (03-02) permet http://localhost:5173 perquè el frontend de l'equip funcioni en desenvolupament. Aquell origen no ha d'existir en producció.
@Bean
@Profile("prod")
CorsConfigurationSource corsProduccio() {
var config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://panel.ribalta.example")); // sense localhost
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(false); // fem servir Bearer, no galetes
config.setMaxAge(3600L);
var font = new UrlBasedCorsConfigurationSource();
font.registerCorsConfiguration("/api/**", config);
return font;
}Dos recordatoris: allowedOrigins("*") juntament amb allowCredentials(true) està prohibit per l'especificació i Spring ho rebutja en arrencar; i CORS no és un mecanisme de seguretat del servidor, sinó una política que aplica el navegador —curl la ignora—. Restringir CORS protegeix els teus usuaris de pàgines malicioses; no protegeix la teva API.
7.2. Limitació de taxa
Sense límit de peticions, un sol client pot saturar l'API o provar contrasenyes sense descans. Les opcions, de fora cap a dins:
| On | Eina | Avantatge | Inconvenient |
|---|---|---|---|
| Passarel·la o CDN | API Gateway, Cloudflare, Nginx | No consumeix recursos de l'aplicació | Menys context de negoci |
| Aplicació | Bucket4j, Resilience4j | Coneix l'usuari i l'endpoint | Consumeix CPU i memòria |
| Base de dades | Comptadors propis | Control total | Lent i complex |
La recomanació és limitar a la passarel·la i deixar a l'aplicació només les regles que necessiten context, com «cinc intents d'inici de sessió per minut i correu». Amb Bucket4j:
@Component
public class FiltreLimitTaxa extends OncePerRequestFilter {
private final Map<String, Bucket> cubells = new ConcurrentHashMap<>();
private Bucket nouCubell() { // 100 peticions per minut, reposició contínua
return Bucket.builder().addLimit(
l -> l.capacity(100).refillGreedy(100, Duration.ofMinutes(1))).build();
}
@Override
protected void doFilterInternal(HttpServletRequest peticio, HttpServletResponse resposta,
FilterChain cadena) throws ServletException, IOException {
Bucket cubell = cubells.computeIfAbsent(clauDe(peticio), k -> nouCubell());
if (cubell.tryConsume(1)) {
cadena.doFilter(peticio, resposta);
} else {
resposta.setStatus(429); // Too Many Requests
resposta.setHeader("Retry-After", "60");
}
}
}Tres advertiments. El ConcurrentHashMap no serveix amb diverses instàncies —cada rèplica tindria el seu comptador— ni evita créixer sense límit: en producció, Redis (09-02). La clau s'ha de triar amb cura: per IP castiga tots els usuaris darrere d'una mateixa NAT; per usuari autenticat és més just, però no protegeix l'inici de sessió, on encara no hi ha usuari. I l'estat correcte és 429 amb Retry-After, perquè el client sàpiga quan reintentar.
7.3. Mida màxima de petició
Un cos enorme o una capçalera monstruosa són una manera barata de denegació de servei.
server:
max-http-request-header-size: 16KB # per defecte 8KB; els JWT ocupen
tomcat:
max-swallow-size: 2MB
connection-timeout: 5s
spring:
servlet:
multipart:
max-file-size: 5MB
max-request-size: 10MBmax-http-request-header-size mereix una nota: el valor per defecte de 8 KB n'hi ha ben bé prou per a un JWT de CicloUrbana, però amb tokens grans d'un proveïdor extern —amb molts claims— pot quedar-se curt i produir un 431 Request Header Fields Too Large difícil de diagnosticar. Apuja'l amb criteri, no «per si de cas».
7.4. HTTPS obligatori i HSTS
Sense TLS, tot el mòdul és paper mullat: el token viatja llegible per la xarxa i qualsevol que sigui a la mateixa wifi el captura. L'habitual és terminar TLS al balancejador o l'ingress, però si l'aplicació ho fa directament:
server:
ssl:
enabled: true
key-store: ${RUTA_MAGATZEM_CLAUS} # mai dins del repositori
key-store-password: ${CLAU_MAGATZEM}
key-store-type: PKCS12I per rebutjar qualsevol petició que arribi sense xifrar, més HSTS (05-02):
.requiresChannel(canal -> canal.anyRequest().requiresSecure())
.headers(h -> h.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true).maxAgeInSeconds(31_536_000)))Quan TLS acaba en un proxy, l'aplicació veu peticions HTTP i requiresSecure() produiria un bucle de redireccions. La solució és que el proxy enviï les capçaleres X-Forwarded-* i activar server.forward-headers-strategy: framework.
7.5. Amagar la versió del servidor i el que no ha de ser a producció
Cada dada que l'API revela sobre si mateixa facilita buscar un exploit conegut.
server:
error:
include-stacktrace: never # ja des de 03-06
include-message: never
whitelabel: { enabled: false }
tomcat:
remoteip: { protocol-header: x-forwarded-proto }I la llista del que no ha de ser accessible en producció:
| Element | Com es tanca |
|---|---|
Swagger UI i /v3/api-docs |
springdoc.api-docs.enabled: false a application-prod.yml |
| Consola H2 | No existeix: PostgreSQL des de 04-02. Verificar que la dependència sigui test |
| Endpoints d'Actuator | Exposar només health i info; la resta, amb ADMIN (07-01) |
Log de seguretat en DEBUG |
Mai fora de desenvolupament (05-02) |
| Dades de prova de Flyway | locations separat per entorn (04-08) |
Tot això es governa amb perfils, el mecanisme que s'estudia a 07-02. La regla és que el perfil prod no hereti res perillós del de desenvolupament, i la comprovació pràctica és llançar l'aplicació amb --spring.profiles.active=prod i verificar que /swagger-ui.html respon 404.
7.6. No filtrar detalls als errors
Revisant 03-06 amb ulls de seguretat, un error pot regalar informació valuosíssima: noms de taules, rutes del sistema de fitxers, versions de llibreries, l'estructura interna del codi.
| Fuita | Exemple | Solució |
|---|---|---|
| Traça de pila a la resposta | at org.hibernate... |
include-stacktrace: never |
| Missatge d'excepció aliena | ERROR: relation "usuaris"... |
No retornar mai e.getMessage() d'origen desconegut |
| Distingir «no existeix» de «sense permís» | 404 davant de 403 |
Valorar el 404 uniforme |
| Missatges d'inici de sessió diferents | «correu no registrat» | Missatge únic (05-03) |
| Temps de resposta diferents | Inici de sessió ràpid si no existeix | Ho resol DaoAuthenticationProvider (05-03) |
El GestorGlobalExcepcions de 03-06 ja ho fa bé: missatges controlats, codi propi i un identificador de rastreig que permet al suport trobar el detall al log del servidor, no a la resposta.
- Els riscos OWASP i la seva mitigació a CicloUrbana
Tanquem el cercle obert a 05-01, ara amb noms concrets del projecte:
| Risc | Què seria a CicloUrbana | Mitigació aplicada |
|---|---|---|
| Injecció SQL | ... WHERE correu = ' + entrada + ' |
JPA i @Query parametritzen sempre (04-06): el valor viatja a part de la sentència i mai no s'interpreta com a SQL. El risc reapareix només si algú concatena en una consulta nativa |
| IDOR / BOLA | La Marta finalitza el lloguer 9, que és d'un altre | @PreAuthorize amb @seguretatLloguers.esPropietari (apartat 4) |
| Exposició excessiva de dades | La resposta inclou contrasenyaHash o el DNI |
DTO de resposta com a llistes d'inclusió (03-05) |
| Mass assignment | {"rols":["ADMIN"]} al registre |
DTO de petició: el que no és al DTO no hi arriba (05-03) |
| Autenticació trencada | Contrasenyes febles, sense límit d'intents | BCrypt, esdeveniments de fallada, límit de taxa (05-02, 05-03) |
| Configuració incorrecta | Swagger obert en producció | Perfils (apartat 7.5, 07-02) |
| Registre insuficient | Ningú no veu 10.000 inicis de sessió fallits | Esdeveniments d'autenticació (apartat 9) |
| Components vulnerables | Una llibreria amb un CVE | Dependency-Check (apartat 10) |
Dues observacions que resumeixen el mòdul. La primera: la meitat d'aquestes mitigacions no són de Spring Security. Els DTO de 03-05, les consultes parametritzades de 04-06 i les restriccions de 04-08 protegeixen tant o més que la cadena de filtres. Bona arquitectura i seguretat són en gran manera el mateix.
La segona: la injecció SQL mereix una nota concreta, perquè és el risc la protecció del qual es perd amb més facilitat. Aquests dos casos semblen equivalents i no ho són:
// ✅ SEGUR: el paràmetre viatja separat de la sentència
@Query("SELECT l FROM Lloguer l WHERE l.usuari.correu = :correu")
List<Lloguer> cercarPerCorreu(@Param("correu") String correu);
// ❌ VULNERABLE: concatenació en una consulta nativa
@Query(value = "SELECT * FROM lloguers WHERE estat = '" + "..." + "'",
nativeQuery = true)I hi ha un detall que sorprèn: el nom d'una columna en un ORDER BY dinàmic no es pot parametritzar. Si algú construeix l'ordenació concatenant un paràmetre de la petició, allà hi ha una injecció. La defensa és una llista blanca de columnes ordenables, no un intent d'escapament.
- Auditoria d'esdeveniments de seguretat
Spring Security publica esdeveniments d'aplicació a cada intent d'autenticació, i escoltar-los costa molt poc:
@Component
public class AuditoriaSeguretat {
private static final Logger log = LoggerFactory.getLogger("AUDITORIA");
@EventListener
public void alEntrar(AuthenticationSuccessEvent e) {
log.info("LOGIN_OK usuari={} rastre={}",
e.getAuthentication().getName(), MDC.get(FiltreRastreig.CLAU_MDC));
}
/** Classe pare: cobreix credencials dolentes, compte desactivat, bloquejat i caducat. */
@EventListener
public void alFallar(AbstractAuthenticationFailureEvent e) {
log.warn("LOGIN_FALLIT usuari={} motiu={} rastre={}", e.getAuthentication().getName(),
e.getException().getClass().getSimpleName(), MDC.get(FiltreRastreig.CLAU_MDC));
}
@EventListener
public void alDenegar(AuthorizationDeniedEvent<?> e) {
log.warn("ACCES_DENEGAT usuari={} rastre={}",
e.getAuthentication().get().getName(), MDC.get(FiltreRastreig.CLAU_MDC));
}
}| Què registrar sempre | Què no registrar mai |
|---|---|
| Inicis de sessió correctes i fallits | Contrasenyes, ni tan sols parcials |
Accessos denegats (403) |
Tokens JWT, ni tan sols truncats |
| Canvis de rols i permisos | Resums de contrasenya |
| Registre i baixa d'usuaris | Capçaleres Authorization completes |
| Canvis de contrasenya | Dades personals innecessàries (RGPD) |
| L'identificador de rastreig i la IP | Galetes de sessió |
Advertiment. Un token o una contrasenya en un log és una credencial en un log, i els logs es copien a sistemes d'agregació, s'envien a tercers i es conserven anys. Quan algú detecta la fuita, aquella credencial fa mesos que circula. Revisa també les llibreries: alguns clients HTTP registren les capçaleres completes en
DEBUG.
El tractament de logs en profunditat —format estructurat, agregació, retenció— es veu a 09-05, i l'alerta automàtica davant d'un pic de fallades, a 09-04.
- Dependències vulnerables
Una aplicació arrossega desenes de dependències transitives, i una vulnerabilitat crítica en qualsevol d'elles és una vulnerabilitat de CicloUrbana. OWASP Dependency-Check compara l'arbre de dependències amb la base pública de vulnerabilitats:
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>10.0.4</version>
<configuration>
<failBuildOnCVSS>7</failBuildOnCVSS> <!-- falla davant de severitat alta -->
<nvdApiKey>${env.NVD_API_KEY}</nvdApiKey>
</configuration>
</plugin>mvn org.owasp:dependency-check-maven:check # informe a target/
mvn versions:display-dependency-updates # quines versions noves hi ha
mvn dependency:tree # d'on ve cada dependènciaQuatre consells pràctics. Executa'l a la integració contínua, no a mà, amb failBuildOnCVSS perquè una vulnerabilitat alta trenqui la construcció (mòdul 8). Hi haurà falsos positius: gestiona'ls amb un fitxer de supressions documentat, un per un i amb justificació, mai abaixant el llindar. Actualitzar la versió de Spring Boot és la via més eficaç, perquè l'starter-parent arrossega desenes de versions coherents de cop, amb atenció especial al que no gestiona, com el JJWT de 05-04. I subscriu-te als avisos de seguretat de Spring: una vulnerabilitat coneguda s'explota massivament poques hores després de publicar-se.
- Llista de comprovació abans de producció
| # | Comprovació | Lliçó |
|---|---|---|
| 1 | Cap secret al repositori; tots per variable d'entorn o gestor de secrets | 02-04, 05-04 |
| 2 | HTTPS obligatori, amb HSTS i certificat vàlid | 7.4 |
| 3 | Contrasenyes amb BCrypt (o Argon2) i cost revisat | 05-02 |
| 4 | Secret JWT de com a mínim 256 bits, generat aleatòriament i rotable | 05-04 |
| 5 | Token d'accés curt (≤15 min) amb refresc rotatori i revocable | 05-04 |
| 6 | anyRequest().denyAll() al final de cada cadena |
05-02 |
| 7 | Regles ordenades d'específica a general, revisades una per una | 05-02 |
| 8 | Regles per dada amb @PreAuthorize a tots els recursos d'un usuari |
Aquesta lliçó |
| 9 | Sessió STATELESS i CSRF coherent amb on viu la credencial |
05-02 |
| 10 | CORS sense localhost ni * en producció |
7.1 |
| 11 | Limitació de taxa activa, com a mínim a l'inici de sessió i al registre | 7.2 |
| 12 | Mides màximes de petició i capçalera configurades | 7.3 |
| 13 | Swagger UI, consola H2 i Actuator tancats o protegits | 7.5 |
| 14 | Errors sense traça, sense missatges interns i amb identificador de rastreig | 03-06, 7.6 |
| 15 | DTO d'entrada i de sortida a tots els endpoints | 03-05 |
| 16 | Cap consulta construïda per concatenació | 04-06 |
| 17 | Auditoria d'esdeveniments de seguretat activa i sense credencials als logs | Apartat 9 |
| 18 | Dependency-Check a la integració contínua, sense vulnerabilitats altes | Apartat 10 |
| 19 | Proves automàtiques de les regles de seguretat | Mòdul 6 |
| 20 | Revisió per un professional de seguretat i auditoria externa | — |
Advertiment final, i el més important del mòdul. Aquesta llista és necessària i no és suficient. Tot el que hem construït en aquestes cinc lliçons és un punt de partida didàctic: cobreix els errors més comuns, però no substitueix una revisió professional. Abans d'exposar un servei real —i més si maneja dades personals de ciutadans, com CicloUrbana— la configuració ha de ser revisada per un especialista en seguretat i sotmesa a una prova de penetració per algú que no hagi escrit el codi. La seguretat no és un estat que s'assoleix: és un procés que es manté, amb revisions periòdiques, actualitzacions i atenció als avisos.
Errors Comuns i Consells
Posar @PreAuthorize al controlador. Deixa el servei desprotegit davant de qualsevol altre punt d'entrada: tasques programades, consumidors de missatges o un endpoint nou.
Anotar un mètode private o cridar-lo des de la mateixa classe. El proxy no hi intervé i la comprovació no s'executa, sense cap avís. És la trampa de 04-07 amb conseqüències pitjors.
Fer servir @PostFilter sobre resultats paginats. Trenca la paginació i porta tota la taula a memòria. Filtra a la consulta.
@PostAuthorize en un mètode que escriu. L'efecte ja ha passat; el rollback de la transacció no desfà un correu enviat ni un càrrec fet.
Escriure SpEL complex dins de l'anotació. No es compila, no es prova i ningú no l'entén: extreu-ne un bean de seguretat. I recorda que SpEL no el comprova el compilador, així que hasRole('OPERRARI') arrenca sense protestar i denega sempre; aquestes expressions necessiten proves (mòdul 6).
Confiar en CORS com a mecanisme de seguretat. L'aplica el navegador; curl l'ignora. I no registris mai tokens ni contrasenyes «només en DEBUG»: els logs es copien i es conserven anys.
Consell: escriu la regla de seguretat i la seva prova alhora. Una regla sense prova és una hipòtesi, i el mòdul 6 comença precisament per aquí.
Consell: revisa la seguretat com revises el codi. Un canvi a ConfiguracioSeguretat, a un @PreAuthorize o a un DTO mereix la mateixa atenció que un canvi a la lògica de cobrament.
Exercicis
Exercici 1
IncidenciaService gestiona les avaries de la flota de Ribalta. Aplica-hi seguretat de mètode amb aquests requisits, justificant cada anotació:
- Qualsevol ciutadà autenticat pot crear una incidència sobre una bicicleta.
- Només un
OPERARIpot tancar una incidència. - Un ciutadà pot consultar les incidències que ell mateix va crear; un operari, qualsevol.
- Només un
ADMINpot esborrar una incidència. - El llistat paginat ha de retornar a cada ciutadà només les seves, i a un operari totes.
Exercici 2
Aquesta classe té cinc problemes de seguretat. Troba'ls, explica l'impacte de cadascun i reescriu-la.
@RestController
@RequestMapping("/api/v1/usuaris")
public class UsuariController {
@GetMapping("/{id}")
public Usuari obtenir(@PathVariable Long id) {
return usuariRepositori.findById(id).orElseThrow();
}
@GetMapping("/cercar")
public List<Usuari> cercar(@RequestParam String nom) {
return entityManager.createNativeQuery(
"SELECT * FROM usuaris WHERE nom LIKE '%" + nom + "%'",
Usuari.class).getResultList();
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
private Usuari actualitzar(@PathVariable Long id, @RequestBody Usuari usuari) {
return usuariRepositori.save(usuari);
}
}Exercici 3
L'ajuntament desplegarà CicloUrbana en producció la setmana que ve. Redacta l'informe de les deu comprovacions que faries, ordenades per criticitat, indicant per a cadascuna com la verificaries de manera objectiva.
Solucions
Solució 1
@Service
public class IncidenciaService {
/** 1. Qualsevol identificat reporta una avaria. L'autor NO ve del
* client: es pren del principal (regla de 05-03). */
@PreAuthorize("isAuthenticated()")
@Transactional
public IncidenciaResponse crear(CrearIncidenciaRequest peticio,
UsuariAutenticat autor) { ... }
/** 2. Tancar és manteniment. La jerarquia de 05-03 inclou ADMIN. */
@PreAuthorize("hasRole('OPERARI')")
@Transactional
public IncidenciaResponse tancar(Long idIncidencia, String resolucio) { ... }
/** 3. Regla per dada: bean de seguretat, no SpEL complex. */
@PreAuthorize("hasRole('OPERARI') or "
+ "@seguretatIncidencies.esAutor(#idIncidencia, principal)")
@Transactional(readOnly = true)
public IncidenciaResponse obtenir(Long idIncidencia) { ... }
/** 4. Esborrar destrueix històric de manteniment: només administració. */
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public void esborrar(Long idIncidencia) { ... }
/** 5. El filtratge va a la CONSULTA, mai a @PostFilter. */
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public PaginaResponse<IncidenciaResponse> llistar(UsuariAutenticat usuari, Pageable p) {
Page<Incidencia> pagina = usuari.esOperari()
? incidenciaRepositori.findAll(p)
: incidenciaRepositori.findByAutorId(usuari.getIdUsuari(), p);
return PaginaResponse.de(pagina.map(mapper::aResposta));
}
}Justificacions. El punt 1 fa servir isAuthenticated() i no hasRole('CIUTADA'), perquè un operari també ha de poder reportar; i l'autor es pren del principal perquè ningú no pugui crear incidències a nom d'un altre. El punt 3 és l'única regla que depèn de la dada i per això necessita un bean, SeguretatIncidencies, amb la mateixa forma que SeguretatLloguers. El punt 5 és el més instructiu: la decisió de quina consulta executar és una decisió de seguretat, i prendre-la a la consulta —en lloc de amb @PostFilter— és l'únic compatible amb paginació. El mètode esOperari() d'UsuariAutenticat encapsula la comprovació de l'autoritat ROLE_OPERARI, que altrament es repetiria per tot el projecte.
Solució 2
Problema 1 — retorna l'entitat Usuari. La resposta inclou contrasenyaHash, els rols i qualsevol camp que s'hi afegeixi en el futur: és exposició excessiva de dades, la fallada que va motivar els DTO de 03-05.
Problema 2 — IDOR a obtenir. No hi ha cap comprovació de permís: qualsevol autenticat —o qualsevol, si la regla per URL fallés— llegeix la fitxa de qualsevol ciutadà canviant l'id. És el risc A01/BOLA.
Problema 3 — injecció SQL a cercar. El paràmetre nom es concatena en una consulta nativa. Amb nom = ' OR '1'='1 es retornen tots els usuaris; amb una càrrega útil més elaborada, es poden executar altres sentències.
Problema 4 — @PreAuthorize sobre un mètode private. El proxy no el pot interceptar: l'anotació s'ignora en silenci i el mètode queda sense protecció. A més, un mètode privat no pot ser un gestor de Spring MVC.
Problema 5 — mass assignment a actualitzar. Rep l'entitat completa, així que la petició pot canviar rols, contrasenyaHash, actiu o fins i tot l'id. Un ADMIN compromès —o un error— pot reescriure qualsevol camp.
El controlador queda reduït a transport: tres mètodes public que reben @PathVariable, @RequestParam validats i @Valid @RequestBody ActualitzarUsuariRequest, retornen UsuariResponse o PaginaResponse<UsuariResponse> i deleguen en el servei, on viuen ara totes les regles:
@Service
public class UsuariService {
@PreAuthorize("#id == principal.idUsuari or hasRole('ADMIN')")
@Transactional(readOnly = true)
public UsuariResponse obtenir(Long id) { ... }
@PreAuthorize("hasRole('ADMIN')")
@Transactional(readOnly = true)
public PaginaResponse<UsuariResponse> cercarPerNom(String nom, Pageable p) {
// Consulta derivada: parametritzada per Spring Data, immune a injecció
return PaginaResponse.de(usuariRepositori
.findByNomContainingIgnoreCase(nom, p).map(mapper::aResposta));
}
/** public, no private: si no, el proxy no l'intercepta i la regla no s'aplica. */
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public UsuariResponse actualitzar(Long id, ActualitzarUsuariRequest peticio) {
Usuari usuari = usuariRepositori.findById(id)
.orElseThrow(() -> new RecursNoTrobatException("Usuari", id));
usuari.setNom(peticio.nom()); // només el que el DTO permet
usuari.setTipusTarifa(peticio.tipusTarifa());
return mapper.aResposta(usuari);
}
}ActualitzarUsuariRequest conté només nom i tipusTarifa: ni rols, ni contrasenya, ni actiu, ni id. Aquest és el punt: el que no és al DTO no es pot modificar, i el canvi de rols viu al seu propi endpoint amb les seves pròpies regles (exercici 3 de 05-03).
Solució 3
| # | Comprovació | Verificació objectiva |
|---|---|---|
| 1 | Cap secret al repositori | git log -p filtrat amb una eina de detecció de secrets; revisar l'historial complet, no només l'última versió |
| 2 | HTTPS obligatori i certificat vàlid | curl -I http://api... ha de redirigir o rebutjar; comprovar la cadena i la caducitat del certificat |
| 3 | Denegació per defecte | Llançar peticions a rutes inexistents i no classificades: totes han de donar 401 o 403, mai 200 |
| 4 | Cada usuari només veu el que és seu | Amb dos ciutadans reals, intentar creuar identificadors en lloguers, incidències i fitxes: tot 403 |
| 5 | Documentació i consoles tancades | /swagger-ui.html, /v3/api-docs, /h2-console i /actuator/env han de respondre 404 o 401 |
| 6 | Errors sense informació interna | Provocar un 500 i comprovar que la resposta no conté traça, SQL ni rutes, i que sí que porta identificador de rastreig |
| 7 | Límit de taxa actiu | Llançar 200 peticions seguides a /api/v1/auth/login i comprovar que apareix el 429 |
| 8 | Sense vulnerabilitats altes | mvn dependency-check:check amb failBuildOnCVSS=7 en verd, i l'informe revisat |
| 9 | Auditoria sense credencials | Buscar als logs d'una sessió completa cadenes com eyJ, Bearer o contrasenya: zero resultats |
| 10 | Revisió professional | Informe signat de la revisió de seguretat i de la prova de penetració |
Criteri d'ordenació: primer el que compromet tot el sistema de cop (un secret filtrat, trànsit sense xifrar), després el que compromet dades de tercers (denegació per defecte, accés creuat), després el que facilita l'atac (documentació oberta, fuites als errors) i per últim el preventiu. I una observació sobre la número 1 que s'oblida sempre: un secret que va ser en un commit i després es va esborrar continua sent a l'historial de Git, així que esborrar-lo no basta: cal rotar-lo.
Conclusió
El mòdul 5 es tanca amb la xarxa de Ribalta protegida de dalt a baix. Has entès per què les regles per URL de 05-02, tot i ser necessàries, no basten: no poden expressar regles que depenen de la dada, no cobreixen els punts d'entrada que encara no existeixen i deixen la protecció lluny del codi que protegeixen. La seguretat de mètode és la capa que completa la perimetral, i ara saps triar entre les seves anotacions amb criteri: @PreAuthorize com a opció per defecte, @PostAuthorize només quan el permís depèn del resultat i mai sobre mètodes que escriuen, @PostFilter pràcticament mai —perquè porta tota la taula a memòria i trenca la paginació de 04-05— i @Secured o @RolesAllowed únicament en codi heretat. Domines el vocabulari SpEL —hasRole, principal, #parametre, @bean.metode(...)— i el seu gran advertiment: no el comprova el compilador, així que una regla mal escrita arrenca sense protestar i denega sempre.
Has tancat el forat amb què començava aquest mòdul mitjançant SeguretatLloguers.esPropietari, un bean llegible, comprovable, reutilitzable i eficient, i saps que les anotacions viuen al servei i funcionen amb proxies, amb la mateixa trampa d'autoinvocació de @Transactional i una conseqüència pitjor: la comprovació que no s'executa no avisa. I has endurit l'API punt per punt: CORS sense localhost en producció i sense la il·lusió que CORS protegeixi el servidor, limitació de taxa amb 429 i Retry-After, mides màximes de petició, HTTPS obligatori amb HSTS i X-Forwarded-*, Swagger i Actuator tancats fora de desenvolupament, errors que no filtren res, la taula OWASP amb la mitigació concreta de cada risc —inclosa la nota sobre l'ORDER BY dinàmic que cap paràmetre no pot protegir—, l'auditoria d'esdeveniments amb la seva llista del que no es registra mai, la revisió de dependències amb Dependency-Check i una llista de vint comprovacions l'últim punt de la qual és el més important: això és un punt de partida didàctic i necessita la revisió d'un professional de seguretat i una auditoria independent abans d'exposar-se a Internet.
CicloUrbana té per fi portes, panys i un registre de qui entra per cadascuna. Però hi ha una pregunta incòmoda que travessa tot el que hem construït i que fins ara hem respost amb curl i bona voluntat: com sabem que funciona? Ningú no ha comprovat automàticament que un ciutadà no pugui finalitzar el lloguer d'un altre, que anyRequest().denyAll() tanqui de debò el que ens pensem, que el token caduqui als quinze minuts o que la migració V4 deixi l'esquema tal com esperem. Cada canvi futur —un @PreAuthorize retocat, una regla reordenada, una dependència actualitzada— pot trencar en silenci qualsevol d'aquestes garanties, i ho descobriríem en producció. El mòdul 6, Proves a Spring Boot, ho resol: veurem la piràmide de proves i què val la pena provar, escriurem proves unitàries amb JUnit 5 i dobles amb Mockito, proves d'integració amb @SpringBootTest, @WebMvcTest i @DataJpaTest —incloses les de seguretat amb @WithMockUser, que convertiran en assercions automàtiques cada regla d'aquest mòdul— i aixecarem un PostgreSQL real i efímer amb Testcontainers per comprovar que Flyway, les entitats i les consultes es comporten igual que a Ribalta. La xarxa de Ribalta està protegida; ara cal demostrar-ho.
Curs de Spring Boot
Mòdul 1: Introducció a Spring Boot
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
