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

  1. Per què les regles per URL no basten
  2. @EnableMethodSecurity i les seves anotacions
  3. SpEL a les expressions de seguretat
  4. Un avaluador de permisos propi: SeguretatLloguers
  5. @PostAuthorize i @PostFilter: el cost de filtrar tard
  6. On posar les anotacions: proxies i autoinvocació
  7. Enduriment de l'API
  8. Els riscos OWASP i la seva mitigació a CicloUrbana
  9. Auditoria d'esdeveniments de seguretat
  10. Dependències vulnerables
  11. Llista de comprovació abans de producció
  12. Errors Comuns i Consells
  13. Exercicis

  1. 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"]

  1. @EnableMethodSecurity i les seves anotacions

S'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.

  1. 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. #idUsuari només funciona si el compilador conserva els noms dels arguments. El spring-boot-maven-plugin activa -parameters per 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 #p0 per 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.

  1. Un avaluador de permisos propi: SeguretatLloguers

Tornem 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:

  1. És llegible. «És operari o administrador, o és el propietari» es llegeix de seguida i es pot ensenyar a l'ajuntament.
  2. És comprovable. SeguretatLloguers és un bean normal: es prova amb JUnit i Mockito (06-02, 06-03) sense aixecar el context de seguretat.
  3. És reutilitzable. La mateixa expressió serveix a obtenirPerId, a cancellar i a qualsevol mètode futur.
  4. És eficient. existsByIdAndUsuariId és una consulta derivada (04-06) que retorna un booleà; no carrega l'entitat ni les seves relacions.
  5. 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 403 confirma que el lloguer 9 existeix. En un servei on la mera existència sigui informació sensible, la resposta correcta és 404: «aquí no hi ha res per a tu». Per a CicloUrbana el 403 és acceptable i més clar; en una aplicació amb dades delicades, planteja't el 404.

  1. @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.

  1. 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.

  1. 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: 10MB

max-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: PKCS12

I 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.

  1. 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.

  1. 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.

  1. 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ència

Quatre 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.

  1. 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ó:

  1. Qualsevol ciutadà autenticat pot crear una incidència sobre una bicicleta.
  2. Només un OPERARI pot tancar una incidència.
  3. Un ciutadà pot consultar les incidències que ell mateix va crear; un operari, qualsevol.
  4. Només un ADMIN pot esborrar una incidència.
  5. 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

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats