La lliçó anterior va tancar la xarxa, però va deixar una porta que ha de continuar oberta: el port 443 de l'API. Allà viu el producte de Nimbus, i cap regla de tallafoc no sap distingir una reserva legítima d'un intent de llegir les dades d'una altra clínica. Aquesta distinció només la pot fer l'aplicació. Aquesta lliçó entra al codi: com s'integra la seguretat al cicle de desenvolupament, com es corregeixen de debò els atacs del catàleg de 02-02, com es generalitza la lliçó de l'IDOR perquè no torni a passar per oblit, i com s'automatitza tot això en un pipeline que trenca el build quan toca.

Contingut

  1. El cicle de desenvolupament segur (SSDLC)
  2. Validació d'entrada: permetre en lloc de bloquejar
  3. Injecció: consultes parametritzades i l'ORM mal usat
  4. XSS, CSRF i el navegador com a frontera
  5. Control d'accés: de l'IDOR a l'autorització centralitzada
  6. SSRF, deserialització i càrrega de fitxers
  7. Seguretat d'APIs
  8. Gestió de secrets al codi i al desplegament
  9. Capçaleres de seguretat i desplegament d'una CSP
  10. Dependències i cadena de subministrament del producte
  11. Proves de seguretat al CI i revisió de codi
  12. Errors, frontend, mòbil i mètriques

  1. El cicle de desenvolupament segur (SSDLC)

La seguretat no és una fase abans del llançament: és una propietat que es decideix a cada etapa. La dada que justifica l'enfocament és coneguda i consistent a la indústria: corregir una fallada en disseny costa una unitat; en desenvolupament, unes cinc; en proves, unes quinze; i en producció, entre trenta i cent —perquè inclou redisseny, desplegament urgent, comunicació al client i, de vegades, notificació de bretxa—. L'IDOR de Nimbus ho il·lustra: detectat en disseny hauria estat una decisió d'arquitectura de deu minuts; detectat al pentest van ser quatre hores de desenvolupament, dues de proves, una nova prova de verificació i una entrada al registre de riscos.

flowchart LR
    R["REQUISITS\nSeguretat com a criteri\nd'acceptacio, no desig"] --> D["DISSENY\nModelatge d'amenaces\nSTRIDE + DFD (01-04)"]
    D --> C["DESENVOLUPAMENT\nPatrons segurs, gestor de\nsecrets, gitleaks pre-commit"]
    C --> P["PROVES\nSAST + SCA + DAST\nal CI (11)"]
    P --> DE["DESPLEGAMENT\nSignatura d'artefactes (03-07),\nsecrets injectats"]
    DE --> O["OPERACIO\nDeteccio (05-02), vulns\n(05-01), resposta (04-05)"]
    O -->|"el que s'apren torna al disseny"| D

El que fa que això funcioni en una empresa de cinc persones tècniques no és un procés pesant: són tres punts de control barats. Un requisit de seguretat explícit a les històries que toquen dades personals; mitja hora de modelatge d'amenaces en dissenyar un mòdul nou; i un CI que diu que no. Tota la resta és refinament.


  1. Validació d'entrada: permetre en lloc de bloquejar

Tota entrada és hostil fins que es demostri el contrari, i «entrada» no és només el formulari: són paràmetres d'URL, capçaleres, galetes, fitxers, missatges de cua i respostes de tercers.

Llista de bloquejats (denylist) Llista de permesos (allowlist)
Defineix El que està prohibit El que està permès
Falla en Tot el que no es va imaginar Res: el que és desconegut es rebutja
Manteniment i exemple Constant, sempre per darrere: «rebutjar si conté <script>» Estable: «el nom són 1-80 caràcters d'aquest conjunt»

La llista de bloquejats perd sempre, perquè l'atacant només necessita una codificació que no fos a la llista. La validació correcta defineix la forma esperada de la dada: tipus, longitud, rang, format i conjunt de valors admesos.

from pydantic import BaseModel, Field, EmailStr, field_validator
from datetime import datetime

class CrearReserva(BaseModel):
    # Cada camp declara la seva forma (tipus, longitud, patro). El que no encaixa es
    # rebutja amb un 422 ABANS de tocar la logica de negoci.
    nom: str = Field(min_length=1, max_length=80, pattern=r"^[\w\s'\-\.·àèéíòóúïüçÀÈÉÍÒÓÚÏÜÇ]+$")
    email: EmailStr
    telefon: str = Field(pattern=r"^\+?[0-9]{9,15}$")
    inici: datetime
    notes: str = Field(default="", max_length=500)

    @field_validator("inici")
    @classmethod
    def no_al_passat(cls, v: datetime) -> datetime:
        # Regla de NEGOCI, no de format: sense ella el tipus es correcte i
        # l'aplicacio accepta reserves el 1970. El que es sintactic no basta.
        if v < datetime.now(v.tzinfo):
            raise ValueError("La data d'inici no pot ser al passat")
        return v

Dues precisions que eviten una confusió freqüent. La validació no substitueix la protecció específica de cada destinació: un nom validat continua necessitant consulta parametritzada en anar a SQL i codificació en anar a HTML, perquè el perill no és a la dada sinó al context on s'usa. I la validació es fa sempre al servidor; la del navegador és usabilitat, i saltar-se-la és tan fàcil com fer servir curl.


  1. Injecció: consultes parametritzades i l'ORM mal usat

# MALAMENT — concatenacio. El text de l'usuari es converteix en codi SQL.
db.execute(f"SELECT * FROM reserves WHERE client = '{nom}' AND tenant_id = {tid}")

# MALAMENT TAMBE — l'ORM no protegeix si li passes SQL construit a ma. Aquest es
# l'error real: l'equip creu estar protegit "perque usa l'ORM".
session.execute(text(f"SELECT * FROM reserves WHERE client = '{nom}'"))

# BE — parametritzada: l'estructura viatja per un canal i les dades per un altre.
# El motor MAI no interpreta el parametre com a SQL, contingui el que contingui.
session.execute(
    text("SELECT * FROM reserves WHERE client = :nom AND tenant_id = :tid"),
    {"nom": nom, "tid": tenant_actual})

# BE — API de l'ORM, que parametritza per construccio.
session.query(Reserva).filter(Reserva.client == nom,
                              Reserva.tenant_id == tenant_actual).all()

Per què la parametrització funciona i l'escapada no: en parametritzar, el motor rep primer l'estructura de la consulta, la compila i només després insereix els valors com a dades. No hi ha cap cadena per interpretar. L'escapada manual, en canvi, depèn d'encertar amb cada codificació i amb cada joc de caràcters, i n'hi ha prou amb un cas no contemplat perquè es trenqui.

El que no es pot parametritzar són els identificadors: noms de taula, de columna i la direcció d'ordenació. Aquí només hi ha una solució correcta, i és traduir contra una llista tancada: un diccionari {"data": "inici", "client": "client"} on la clau és el que envia l'usuari i el valor és el nom real de la columna, amb un valor per omissió per al que és desconegut, i una direcció que només pot ser ASC o DESC. Mai no s'interpola directament el que arriba del client.

I dues capes de defensa en profunditat que ja coneixes: el rol nimbus_api sense permisos de DDL (així una injecció no pot crear ni esborrar taules) i RLS a PostgreSQL, que filtra per tenant al mateix motor encara que la consulta s'equivoqui.


  1. XSS, CSRF i el navegador com a frontera

XSS passa quan dades controlades per un usuari acaben interpretades com a codi al navegador d'un altre. La defensa té dues capes.

La primera és codificar a la sortida, segons el context: no es codifica igual un text en HTML, un valor dins d'un atribut, una cadena en JavaScript o un paràmetre en una URL. A la SPA de Nimbus (React) el marc ja escapa el contingut de text per omissió, i el perill es concentra a les excepcions: dangerouslySetInnerHTML, la construcció dinàmica d'URL (href="javascript:...") i les plantilles del servidor. Si cal acceptar HTML enriquit, es sanegen amb una biblioteca de confiança i llista de permesos d'etiquetes i atributs; escriure el sanejador propi és garantia de fallada.

La segona capa és la CSP (§9), que limita el dany quan la primera falla. I una mesura que decideix l'impacte real: la galeta de sessió amb HttpOnly, perquè un XSS no la pugui llegir.

CSRF és diferent: l'atacant no roba res, sinó que fa que el navegador de la víctima executi una acció legítima aprofitant que les galetes s'envien soles.

resposta.set_cookie(        # les quatre propietats que importen
    "sessio", token,
    httponly=True,   # invisible per a JavaScript: neutralitza el robatori per XSS
    secure=True,     # nomes per HTTPS
    samesite="Lax",  # no viatja en POST des d'un altre lloc -> talla CSRF
    max_age=28800, path="/")

SameSite=Lax talla la majoria dels CSRF per si sol, i Strict és encara més estricte a costa de trencar els enllaços entrants. Per a les operacions sensibles s'afegeix el token anti-CSRF amb el patró de doble enviament: un valor aleatori en una galeta llegible i el mateix valor en una capçalera que el JavaScript propi hi afegeix; un lloc aliè no pot llegir la galeta, així que no pot compondre la capçalera. I una regla que resol la meitat del problema per disseny: les peticions que canvien estat mai no són GET.

Nota d'arquitectura: si la SPA autentica amb token a la capçalera Authorization en lloc de galeta, el CSRF clàssic desapareix —el navegador no afegeix aquesta capçalera sol—, però apareix el problema d'on guardar el token, que es tracta a §12.


  1. Control d'accés: de l'IDOR a l'autorització centralitzada

El control d'accés trencat és la fallada número u del Top 10 d'OWASP, i és la que més ha costat a Nimbus: un IDOR a 01-04 i un altre de residual al pentest de 05-03. El patró de l'error es repeteix: l'autorització es decideix a cada ruta, i n'hi ha prou que un desenvolupador se n'oblidi una vegada.

# ABANS — cada endpoint recorda (o no) filtrar. L'IDOR es questio de temps.
@router.get("/reservas/{reserva_id}")
def veure(reserva_id: int, db=Depends(get_db)):
    return db.get(Reserva, reserva_id)      # retorna la reserva DE QUALSEVOL

# DESPRES — l'autoritzacio es centralitza en dependencies reutilitzables.

def usuari_actual(token: str = Depends(oauth2)) -> Usuari:
    return verificar_token(token)                     # 03-07

def tenant_actual(u: Usuari = Depends(usuari_actual)) -> int:
    return u.tenant_id      # del token verificat, MAI de la peticio

def exigeix(*permisos: str):
    """Fabrica de dependencies: declara el permis necessari a la propia ruta."""
    def comprovar(u: Usuari = Depends(usuari_actual)) -> Usuari:
        if set(permisos) - set(ROLES[u.rol]):         # roles.yaml (modul 2)
            # Registrar la denegacio alimenta la deteccio d'escalada (05-02).
            log.warning("autz.denegada", extra={"camps": {"actor_id": u.id}})
            raise HTTPException(403, "No autoritzat")
        return u
    return comprovar

def obtenir_reserva(reserva_id: int, tid: int = Depends(tenant_actual),
                    db=Depends(get_db)) -> Reserva:
    """Punt UNIC on es resol una reserva: el filtre per tenant viu
    aqui, aixi que saltar-se'l exigeix un acte deliberat i no un oblit."""
    r = db.query(Reserva).filter(Reserva.id == reserva_id,
                                 Reserva.tenant_id == tid).first()
    if not r:
        # 404 i no 403: un 403 confirmaria que aquest id existeix en UN ALTRE tenant.
        raise HTTPException(404, "No trobada")
    return r

@router.get("/reservas/{reserva_id}")
def veure(reserva: Reserva = Depends(obtenir_reserva),
          _=Depends(exigeix("reserves:llegir"))):
    return reserva

Cinc principis es materialitzen en aquest codi: denegar per omissió (sense permís declarat, no es passa); l'identificador de tenant no és mai entrada de l'usuari; un únic punt de resolució per recurs, de manera que la fallada requereixi un acte deliberat i no un oblit; 404 en lloc de 403 per no confirmar existències alienes; i registre de cada denegació, que és la font de la detecció d'escalada.

Tres reforços completen el model: RLS a PostgreSQL com a xarxa inferior per si la consulta s'equivoca; proves automàtiques d'aïllament entre tenants al CI —una prova per cada endpoint que intenti, amb el token del tenant A, accedir a un recurs del tenant B i exigeixi 404—; i la comprovació que els identificadors no siguin predictibles (UUID en lloc d'enters consecutius), que no és un control per si sol però eleva molt el cost de descobrir la fallada.


  1. SSRF, deserialització i càrrega de fitxers

SSRF és aconseguir que el teu servidor faci una petició a una destinació triada per l'atacant. Al núvol és especialment greu perquè el servei de metadades de la instància respon en una IP interna i lliura credencials.

import ipaddress, socket
from urllib.parse import urlparse

DOMINIS_PERMESOS = {"api.pasarela-ejemplo.com", "hooks.proveedor-correo.com"}

def url_segura(url: str) -> str:
    p = urlparse(url)
    if p.scheme != "https" or p.hostname not in DOMINIS_PERMESOS:
        raise ValueError("Desti no permes")             # LLISTA DE PERMESOS
    # Comprovar tambe la IP resolta: un domini permes pot apuntar a
    # una IP interna, i sense aixo l'allowlist de noms es burla via DNS.
    ip = ipaddress.ip_address(socket.gethostbyname(p.hostname))
    if ip.is_private or ip.is_loopback or ip.is_link_local:
        raise ValueError("Desti intern bloquejat")      # cobreix 169.254.169.254
    return url

Complements imprescindibles: bloquejar el servei de metadades al tallafoc de sortida (05-04) i exigir-ne la versió amb sessió (IMDSv2 o equivalent, 05-07); no seguir redireccions automàticament, perquè una destinació permesa pot redirigir a una d'interna; i temps d'espera curts.

Deserialització insegura: no es deserialitza mai un format que pugui instanciar objectes arbitraris (pickle, yaml.load sense SafeLoader, eval) amb dades que vinguin de fora. Es fa servir JSON amb esquema validat, i punt.

Càrrega de fitxers (els comunicats escanejats del bucket A-02), amb sis controls que s'apliquen en conjunt:

TIPUS = {"application/pdf": ".pdf", "image/jpeg": ".jpg", "image/png": ".png"}
MAX = 10 * 1024 * 1024

async def pujar(f: UploadFile, tid: int = Depends(tenant_actual)):
    dades = await f.read(MAX + 1)
    if len(dades) > MAX:
        raise HTTPException(413, "Fitxer massa gran")          # 1) mida
    # 2) Tipus REAL per contingut, no per extensio ni per Content-Type: tots dos
    #    els controla l'atacant.
    mime = magic.from_buffer(dades, mime=True)
    if mime not in TIPUS:
        raise HTTPException(415, "Tipus no permes")
    clau = f"tenant/{tid}/{uuid4().hex}{TIPUS[mime]}"   # 3) nom generat
    # 4) Fora del webroot, al bucket privat (mai public).
    s3.put_object(Bucket="nimbus-adjuntos-prod", Key=clau, Body=dades,
                  ContentType=mime, ServerSideEncryption="aws:kms")
    cua.enviar("analitzar_adjunt", {"clau": clau})           # 5) antivirus
    return {"clau": clau}

I el sisè control, ja conegut: la descàrrega se serveix amb URL signada de 120 segons (03-07) generada després de comprovar l'autorització, mai amb un enllaç públic ni servint el fitxer des del servidor de l'aplicació.


  1. Seguretat d'APIs

L'OWASP API Security Top 10 existeix perquè les APIs fallen de forma diferent de les webs: no hi ha interfície que limiti el que es pot demanar.

Risc Què és A Nimbus
API1 BOLA Autorització trencada a nivell d'objecte: accedir al recurs d'un altre L'IDOR. Resolt a §5
API2 Autenticació trencada · API3 BOPLA Login sense límit de taxa · autorització trencada a nivell de propietat: retornar o acceptar camps de més Corregit a 05-03 · el perfil retornava rol i tenant_id, i acceptava rol al PATCH
API4 Consum sense límit · API5 Autorització de funció · API7 SSRF Sense paginació ni quotes · un usuari normal invoca un endpoint administratiu · §6 Quota per tenant · exigeix("...") a totes les rutes · llista de permesos
API8 Mala configuració · API9 Inventari CORS obert, errors verbosos · versions i entorns sense retirar CORS explícit · v0 viva sense control, i A-22

BOPLA mereix codi, perquè és el més silenciós: l'objecte es retorna sencer «perquè és còmode» i filtra camps interns, o s'accepta sencer i permet que un client s'ascendeixi a administrador.

class PerfilSortida(BaseModel):              # llista blanca de SORTIDA:
    id: UUID; nom: str; email: EmailStr      # `rol`, `tenant_id` i hash no surten
class PerfilEntrada(BaseModel):
    nom: str | None = None; telefon: str | None = None
    model_config = {"extra": "forbid"}       # un `rol` al cos -> error 422

@router.patch("/perfil", response_model=PerfilSortida)
def actualitzar(canvis: PerfilEntrada, u: Usuari = Depends(usuari_actual)):
    return repo.actualitzar(u.id, canvis.model_dump(exclude_unset=True))

extra: "forbid" és la línia que impedeix l'assignació massiva: sense ella, qualsevol camp enviat que coincideixi amb un atribut del model s'aplicaria en silenci. I response_model garanteix que la sortida es filtri sempre, encara que l'objecte intern creixi amb camps nous en el futur.

Completen el capítol: límit de taxa per client i per tenant, no només per IP (una clínica amb cent empleats comparteix IP, i un atacant amb cent IP comparteix compte); versionat amb data de retirada anunciada, perquè el risc més gran del versionat és la v0 que ningú no va apagar; paginació obligatòria amb sostre dur al servidor (limit màxim 100, encara que en demanin 10.000); CORS amb llista explícita d'orígens, mai * amb credencials; i documentació que no filtri —l'esquema OpenAPI públic descriu només els endpoints públics, i /docs no se serveix en producció sense autenticació—.


  1. Gestió de secrets al codi i al desplegament

Mètode Seguretat Quan
Secret al codi Inacceptable Mai. És R-06 i el dia 5 de 02-06
Fitxer .env al servidor · variable d'entorn injectada Baixa · mitjana Desenvolupament local amb .gitignore · acceptable si l'origen és un gestor
Gestor de secrets amb accés per rol i rotació Alta Objectiu de Nimbus (03-06); al CI, variables xifrades per entorn

Tres regles operatives. El contenidor rep el secret a l'arrencada, des del gestor i amb una identitat de servei, i mai no el porta incrustat a la imatge (05-07). Els secrets del CI es limiten per entorn, de manera que un pull request d'una branca no accedeixi a les credencials de producció —una fallada molt comuna i molt explotable—. I la prevenció es posa abans de l'error: gitleaks declarat a .pre-commit-config.yaml (repositori gitleaks/gitleaks, rev: v8.18.4, hooks: [{id: gitleaks}]) és l'única barrera que actua abans que el secret entri a l'historial. L'escaneig del CI (§11) és la xarxa per a qui no tingui el hook instal·lat, i la rotació immediata continua sent obligatòria quan alguna cosa s'hi cola.


  1. Capçaleres de seguretat i desplegament d'una CSP

# CSP: la defensa mes potent contra XSS. 'self' = nomes el nostre propi origen.
# Sense 'unsafe-inline' ni 'unsafe-eval', que anullen gran part del seu valor.
add_header Content-Security-Policy "default-src 'self'; script-src 'self';
  style-src 'self'; img-src 'self' data:; font-src 'self';
  connect-src 'self' https://api.nimbusreservas.example;
  frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" always;

# HTTPS obligatori un any, subdominis inclosos. Nomes quan TOTS serveixin
# HTTPS: revertir-ho als navegadors triga mesos.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

# Impedeix que el navegador "endevini" el tipus i executi com a script el que es
# va servir com a text o imatge.
add_header X-Content-Type-Options "nosniff" always;

# Limita el que viatja al Referer cap a altres llocs (URL amb identificadors).
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Desactiva capacitats del navegador que l'aplicacio no fa servir.
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
server_tokens off;   # i el que NO hi ha de ser: la versio del servidor

Notes: frame-ancestors 'none' substitueix l'antic X-Frame-Options i evita el clickjacking; always fa que la capçalera s'enviï també en respostes d'error, que és on s'oblida; i preload només se sol·licita quan s'està segur, perquè revertir-ho triga mesos.

Com es desplega una CSP sense trencar la SPA, en quatre passos: (1) publicar la política com a Content-Security-Policy-Report-Only amb un report-uri propi, de manera que no bloquegi res i només informi; (2) recollir informes dues setmanes, que és el que triga a passar per tots els fluxos reals dels usuaris; (3) ajustar la política per als orígens legítims que apareguin —normalment analítica, fonts i algun iframe de pagament—, sense caure en la temptació d'afegir 'unsafe-inline' per acallar els avisos, perquè això desactiva la protecció principal; i (4) canviar a mode bloquejant i mantenir el report-uri, que a partir d'aquí es converteix en un senyal de detecció: informes nous poden significar un XSS en curs.


  1. Dependències i cadena de subministrament del producte

Enllaçant amb 04-04, la política de Nimbus té cinc peces. Fixació per hash: requirements.txt generat amb pip-compile --generate-hashes i instal·lat amb pip install --require-hashes, de manera que una versió republicada amb contingut diferent trenca la instal·lació en lloc d'entrar sense avisar. Bot d'actualitzacions (Dependabot o Renovate) que obre un pull request per dependència, agrupant les de pedaç setmanalment i separant les majors, amb la política de terminis de 05-01. Cobertura de proves suficient per poder fusionar una actualització de seguretat el mateix dia sense por: sense proves, l'aplicació de pedaços es posposa i aquesta és la causa real de la majoria dels retards. SBOM generat a cada release i publicat juntament amb l'artefacte, més el VEX de 05-01. I avaluació abans d'afegir una dependència nova: està mantinguda?, quants mantenidors té?, quantes dependències transitives arrossega?, podríem escriure en vint línies el que fa? Una dependència de tres línies amb dotze de transitives és pitjor negoci que escriure-la.


  1. Proves de seguretat al CI i revisió de codi

Una regla semgrep pròpia val més que cent regles genèriques, perquè coneix el teu codi:

# .semgrep/nimbus-tenant.yml
rules:
  - id: consulta-sense-filtre-de-tenant
    languages: [python]
    severity: ERROR
    message: >
      Consulta sobre un model multi-tenant sense filtre per tenant_id. Fes servir
      la dependencia `obtenir_recurs` en comptes de consultar directament (§5).
    patterns:
      - pattern-either:                       # 1) que busquem
          - pattern: $DB.query(Reserva)...
          - pattern: $DB.get(Reserva, ...)
      - pattern-not: $DB.query(...).filter(..., Reserva.tenant_id == ..., ...)
      - pattern-not-inside:                   # 2) on SI que esta permes
          def obtenir_reserva(...): ...
    paths: { exclude: ["tests/", "migrations/"] }   # 3) rutes excloses

És la regla que hauria detectat l'IDOR del pentest al mateix pull request. El patró general d'una bona regla pròpia són les tres parts que s'hi veuen: què es busca, on està legítimament permès (pattern-not-inside) i quines rutes s'exclouen. Sense les dues últimes, la regla genera soroll i es desactiva.

# .github/workflows/appsec.yml
name: Seguretat d'aplicacio
on: [pull_request]
permissions: { contents: read, security-events: write }
jobs:
  estatic:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: gitleaks/gitleaks-action@v2          # BLOQUEJA: secrets
      # --error fa fallar el job amb troballes ERROR (les nostres regles
      # propies); les comunitaries entren com a WARNING i informen.
      - run: pip install semgrep pip-audit
      - run: semgrep --config .semgrep/ --config p/python --error --sarif -o sast.sarif
      - run: pip-audit -r requirements.txt --strict
      # BLOQUEJA: verifica que el tenant A no veu dades del B. Es la xarxa que
      # impedeix que un IDOR arribi a produccio una altra vegada.
      - run: pytest tests/seguretat/test_aillament_tenants.py -q
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with: { sarif_file: sast.sarif }
  dast:
    runs-on: ubuntu-latest
    needs: [estatic]
    steps:
      # `baseline` es passiu: no ataca, nomes observa el que respon el
      # servidor. Segur contra preproduccio i acaba en minuts.
      - uses: zaproxy/[email protected]
        with: { target: "https://preprod.nimbusreservas.example" }
        continue-on-error: true      # INFORMA, no bloqueja: el DAST te soroll

La política de què trenca el build és la decisió de disseny més important del pipeline:

Comprovació Bloqueja? Motiu
Secret detectat Sí, sempre No hi ha cas legítim; el cost de deixar-ho passar és una credencial compromesa
Regla semgrep pròpia (ERROR) Patrons que ja ens han fet mal; el fals positiu es documenta com a excepció
Proves d'aïllament entre tenants És el risc R-04, el més greu del producte
SCA: crítica amb pedaç disponible Accionable immediatament
SCA sense pedaç · SAST comunitari · DAST No, avisen Irresoluble o amb soroll alt: bloquejar ensenya a saltar-se el control

Revisió de codi amb enfocament de seguretat. L'Iván revisa cada pull request amb set preguntes, i no amb una lectura general: toca autorització i fa servir les dependències comunes?; alguna consulta concatena, o se salta el filtre per tenant?; alguna entrada nova no té esquema de validació?; la resposta fa servir response_model i no retorna camps de més?; apareix algun secret, URL interna o credencial, encara que sigui en un comentari o en un fitxer de prova?; els errors filtren traces o dades?; la dependència nova està justificada i fixada? Un canvi que toca autorització o dades personals el revisen dues persones, i és l'única regla de procés que Nimbus imposa per escrit.


  1. Errors, frontend, mòbil i mètriques

Maneig d'errors. El client rep un missatge genèric i un identificador; el detall va al log intern.

@app.exception_handler(Exception)
async def error_no_controlat(peticio: Request, exc: Exception):
    # La traca COMPLETA va al log intern (05-02), mai a la resposta.
    log.exception("error.no_controlat", extra={"camps": {"ruta": peticio.url.path}})
    return JSONResponse(status_code=500, content={
        "error": "S'ha produit un error intern",       # sense traca, sense SQL, sense ruta
        "referencia": request_id.get(),                # el Ruben ho creua amb el log
    })

Una traça a la resposta regala versions, rutes del sistema de fitxers, noms de taules i de vegades credencials de connexió. I en producció el mode depuració va desactivat, sense excepcions.

Frontend. Les dependències de la SPA s'auditen igual que les del servidor (npm audit, osv-scanner) i amb el mateix bot. Sobre on guardar el token:

localStorage Galeta HttpOnly + Secure + SameSite
Accessible des de JavaScript : qualsevol XSS el roba No
S'envia sola No (cal afegir-la) Sí → requereix protecció CSRF
Persistència i revocació Fins a esborrar-la; difícil de revocar Caducitat i revocació des del servidor

La galeta HttpOnly és millor opció: l'XSS és més freqüent que el CSRF i les seves conseqüències són pitjors, i el CSRF es resol amb SameSite més token, mentre que un token a localStorage no té cap defensa davant de l'XSS. A més, el frontend no és un control de seguretat: amagar un botó segons el rol és usabilitat; el permís es comprova sempre al servidor.

Aplicació mòbil. Tres mesures: fixació de certificat (pinning) amb pla de rotació —un pinning sense pla de canvi deixa l'app inservible el dia que rota el certificat—; no emmagatzemar secrets al binari, perquè es descompila en minuts; i emmagatzematge segur de credencials al clauer del sistema. El principi que les uneix: no confiïs mai en el client; tota comprovació que importi es repeteix al servidor.

Mètrica d'AppSec Objectiu Nimbus
Cobertura de proves d'aïllament entre tenants 100 % dels endpoints que retornen dades de client
Vulnerabilitats introduïdes per release · MTTR d'aplicació Tendència decreixent · ≤ 7 dies per a les crítiques
PR amb revisió de seguretat en tocar dades personals · dependències amb pedaç sense aplicar > 30 dies 100 % · 0

Errors Comuns i Consells

  • Confiar en la validació del navegador. És usabilitat; curl la ignora. Tota validació es repeteix al servidor.
  • Creure que l'ORM protegeix per si sol. Protegeix si es fa servir la seva API; amb text() i una f-string torna la injecció.
  • Decidir l'autorització a cada endpoint, o retornar 403 en lloc de 404 per a recursos aliens. El primer garanteix que algú se n'oblidi; el segon confirma que aquell identificador existeix en un altre tenant, i això ja és una fuita.
  • Afegir 'unsafe-inline' perquè la CSP deixi de queixar-se. És desactivar-la mantenint l'aparença.
  • Guardar el token a localStorage, o retornar traces d'error al client. El primer el roba qualsevol XSS; el segon regala versions, rutes, noms de taules i de vegades credencials.
  • Consell: escriu una regla semgrep pròpia per cada incident. És la forma més barata que una fallada no passi dues vegades, i hauria detectat l'IDOR al pull request.
  • Consell: les proves d'aïllament entre tenants són la millor inversió del capítol. Una per endpoint, bloquejants al CI, i R-04 deixa de dependre de la memòria de ningú.
  • Consell: desplega la CSP en Report-Only dues setmanes. És la diferència entre protegir la SPA i trencar-la un divendres a la tarda.

Exercicis

Exercici 1 — Revisar un pull request

L'Iván proposa aquest endpoint nou. Identifica tots els problemes de seguretat i reescriu-lo.

@router.post("/informes/exportar")
def exportar(tenant_id: int, format: str, columnes: str, db=Depends(get_db)):
    sql = f"SELECT {columnes} FROM reserves WHERE tenant_id = {tenant_id}"
    files = db.execute(sql).fetchall()
    ruta = f"/var/www/html/export/{tenant_id}_{format}.csv"
    escriure(ruta, files)
    return {"url": f"https://nimbusreservas.example/export/{tenant_id}_{format}.csv"}

Exercici 2 — Dissenyar la defensa d'una funcionalitat nova

El mòdul de teleconsulta permetrà a les clíniques pujar un logotip que es mostrarà a la sala d'espera virtual, i configurar una URL de webhook a la qual Nimbus enviarà avisos quan acabi una consulta.

  1. Enumera els riscos de cadascuna de les dues funcionalitats.
  2. Defineix els controls concrets, indicant on s'aplica cadascun.
  3. Escriu la validació de la URL de webhook i explica quin atac talla cada línia.

Exercici 3 — Decidir la política del pipeline

L'equip discuteix què ha de trencar el build. La Lucía vol bloquejar amb qualsevol troballa alta o crítica de qualsevol eina; l'Iván diu que així no es desplega mai. Proposa una política raonada, indicant per a cada comprovació si bloqueja o informa, i explica com es gestionen les excepcions i quin senyal indicaria que la política està mal calibrada.


Solucions

Exercici 1

Cinc blocs de problemes, de major a menor gravetat:

# Problema Conseqüència
1 tenant_id arriba com a paràmetre IDOR directe: exportar les dades de qualsevol clínica. És la fallada del pentest, repetida
2 columnes i tenant_id es concatenen al SQL Injecció total, i per dues vies. columnes és a més el punt que no es pot parametritzar i aquí no es valida contra llista tancada
3 Escriptura a /var/www/html amb URL predictible El fitxer queda servit públicament i sense autenticació, i qualsevol pot provar identificadors per descarregar exportacions alienes
4 Sense comprovació de permís ni límit de taxa Qualsevol usuari autenticat exporta tot; i un bucle genera un DoS i omple el disc
5 Sense registre d'auditoria Una exportació massiva no queda traçada i D-05 de 05-02 no la pot detectar
COLUMNES = {"data": "inici", "client": "client", "estat": "estat"}  # llista TANCADA

@router.post("/informes/exportar")
@limitar("3/hora")                                        # quota per usuari i tenant
def exportar(peticio: PeticioExport,                      # esquema validat
             u: Usuari = Depends(exigeix("informes:exportar")),
             tid: int = Depends(tenant_actual),           # del token, no de l'URL
             db=Depends(get_db)):
    cols = [COLUMNES[c] for c in peticio.columnes if c in COLUMNES]
    if not cols: raise HTTPException(422, "Columnes no valides")
    files = db.execute(
        text(f"SELECT {', '.join(cols)} FROM reserves "
             "WHERE tenant_id = :t AND inici BETWEEN :d AND :h LIMIT 50000"),
        {"t": tid, "d": peticio.des_de, "h": peticio.fins_a}).fetchall()
    # Bucket PRIVAT, clau aleatoria i URL signada de vida curta: mai al
    # webroot ni amb nom predictible.
    clau = f"export/{tid}/{uuid4().hex}.csv"
    s3.put_object(Bucket="nimbus-adjuntos-prod", Key=clau, Body=a_csv(files))
    auditar("reserva.exportada", actor=u.id, tenant=tid, num_registres=len(files))
    return {"url": url_signada(clau, segons=120)}         # alimenta D-05 (05-02)

Exercici 2

(1) Riscos. El logotip és una càrrega de fitxer: SVG amb JavaScript incrustat (XSS emmagatzemat que s'executa al navegador de tots els pacients d'aquella sala), fitxer enorme que esgota disc o amplada de banda, tipus falsejat per extensió o Content-Type, path traversal al nom i programari maliciós allotjat sota el domini de Nimbus. El webhook és SSRF per disseny: el client tria a on truca el nostre servidor —inclòs 169.254.169.254 per robar credencials de la instància, o adreces internes per escanejar la VPC—, més redireccions cap a destinacions internes, respostes gegants o lentes que esgoten fils, i ús de Nimbus com a amplificador contra tercers.

(2) Controls. Per al logotip: mida i dimensions màximes; tipus real per contingut limitat a PNG i JPEG, amb SVG explícitament prohibit; reprocessament de la imatge, que en recodificar-la elimina qualsevol càrrega incrustada i és el control més eficaç; nom generat pel servidor; emmagatzematge al bucket privat amb URL signada o en un domini de contingut separat perquè una fallada no hereti l'origen de l'aplicació; nosniff en servir-lo; i antivirus. Per al webhook: només HTTPS, validació de la IP resolta amb bloqueig de rangs privats, link-local i loopback; redireccions desactivades; temps d'espera curts i límit de mida de resposta; reintents amb retrocés exponencial i desactivació després de N fallades; enviament des d'una xarxa de sortida separada sense ruta a la VPC (05-04); signatura HMAC del cos perquè el receptor verifiqui l'origen (03-04); i verificació de propietat de l'URL abans d'activar-la.

(3) Validació:

def validar_webhook(url: str) -> str:
    p = urlparse(url)
    if p.scheme != "https":                     # talla http en clar, file://,
        raise ValueError("Nomes HTTPS")         # gopher:// i esquemes rars
    if p.port and p.port != 443:                # talla l'escaneig de ports interns
        raise ValueError("Port no permes")
    ips = {ipaddress.ip_address(i[4][0]) for i in socket.getaddrinfo(p.hostname, None)}
    for ip in ips:                              # TOTES les IP, no nomes la primera:
        if (ip.is_private or ip.is_loopback     # un domini resol a diverses, i
                or ip.is_link_local             # 169.254.169.254 son les metadades
                or ip.is_reserved):
            raise ValueError("Desti intern bloquejat")
    return url

I l'advertiment que fa incompleta qualsevol validació prèvia: entre el moment de validar i el de connectar, el DNS pot canviar (DNS rebinding). Per això la validació al codi és necessària però no suficient, i el control que realment tanca el risc és el de xarxa: el procés que envia webhooks surt per una subxarxa sense ruta cap a la VPC ni cap al servei de metadades.

Exercici 3

Tots dos tenen raó en part, i la síntesi és un criteri, no un llindar: bloqueja el que és inequívoc i accionable ara; informa del que és ambigu o no té solució disponible. Aplicat a Nimbus, la política és la taula del §11: bloquegen els secrets, les regles pròpies de semgrep, les proves d'aïllament entre tenants i les vulnerabilitats crítiques amb pedaç disponible; informen el SAST comunitari, el DAST baseline i les vulnerabilitats sense pedaç.

La postura de la Lucía falla per un motiu concret: bloquejar amb qualsevol troballa alta de qualsevol eina significa que un fals positiu d'una regla genèrica o una CVE sense pedaç en una biblioteca transitiva aturen el desplegament d'una correcció urgent. El resultat previsible no és més seguretat, sinó que algú afegeixi --no-verify o desactivi el job, i llavors es perden també les comprovacions bones. La de l'Iván falla si es porta a l'extrem: un pipeline que no bloqueja mai és un informe, no un control.

Gestió d'excepcions: es declaren al repositori (.semgrepignore, fitxer de supressions del SCA) amb tres camps obligatoris —motiu, responsable i data de caducitat— i es revisen al mateix cicle que les excepcions de 04-02; una supressió sense caducitat és un forat permanent disfressat de decisió tècnica. Senyals de mala calibració, en tots dos sentits: si més del 20 % de les execucions falla per seguretat, o si apareixen commits amb la comprovació saltada, la política és massa estricta i cal moure comprovacions a mode informatiu o afinar regles; si el pipeline fa sis mesos que no bloqueja res i el pentest continua trobant fallades de patró, és massa laxa. La mètrica que resol la discussió és quantes troballes de l'últim pentest haurien estat detectades pel pipeline: si són poques, el problema no és el llindar, són les regles.


Conclusió

Has treballat sobre la superfície que no es pot tancar amb un tallafoc. Saps integrar la seguretat al SSDLC amb tres punts de control barats, i per què corregir en producció costa entre trenta i cent vegades més que en disseny. Domines la validació d'entrada amb llista de permesos i esquemes declaratius, i la distinció que evita falses seguretats: validar no substitueix protegir cada destinació, perquè el perill és al context d'ús, no a la dada.

Saps corregir de debò els atacs de 02-02: consultes parametritzades —i per què un ORM no basta si es fa servir amb text() i f-strings—, amb llista tancada per al que no es pot parametritzar; codificació de sortida i CSP contra XSS, amb HttpOnly perquè un XSS no arribi a la sessió; SameSite i tokens anti-CSRF, més la regla que res que canviï estat sigui GET; SSRF amb llista de permesos, validació de la IP resolta i bloqueig del servei de metadades; i càrrega de fitxers amb sis controls combinats i descàrrega per URL signada. I t'endus la peça central: l'autorització centralitzada en dependències reutilitzables, on el tenant_id surt del token i mai de la petició, amb un punt únic de resolució per recurs, 404 en lloc de 403, denegació registrada, RLS com a xarxa inferior i proves automàtiques d'aïllament entre tenants al CI. L'IDOR deixa de dependre que ningú no se n'oblidi. Coneixes l'OWASP API Top 10 aplicat a Nimbus, amb BOPLA resolt mitjançant response_model i extra: "forbid" contra l'assignació massiva, límit de taxa per client i per tenant, paginació amb sostre dur, CORS explícit, versionat amb retirada anunciada i documentació que no filtra. Saps gestionar secrets amb gestor, injecció a l'arrencada, aïllament per entorn al CI i gitleaks en pre-commit; tens el bloc complet de capçaleres explicades una a una i el procediment de quatre passos per desplegar una CSP sense trencar la SPA, amb el parany d''unsafe-inline' assenyalat; i saps fixar dependències per hash, automatitzar actualitzacions i avaluar una dependència abans d'afegir-la. I tens el pipeline d'AppSec amb una regla semgrep pròpia que hauria caçat l'IDOR al pull request, ZAP baseline informatiu, la política raonada de què trenca el build i les set preguntes de la revisió de codi, a més del maneig segur d'errors, la comparació localStorage davant de galeta HttpOnly, la protecció de l'app mòbil i les mètriques d'AppSec.

El codi ja es defensa. Però tot aquest codi s'executa sobre alguna cosa: un sistema operatiu amb paquets, serveis, usuaris i permisos, i una quarantena de portàtils repartits entre València i les cases de mitja plantilla. Allà segueix viu, des d'01-04, el python -m http.server que ningú no va apagar, i allà hi ha els equips sense xifrar i els comptes amb privilegis d'administrador que no calen. A Enfortiment de Sistemes i Seguretat de l'Endpoint (05-06) apliquem la reducció de superfície al sistema operatiu: línies base reproduïbles amb CIS i OpenSCAP, sshd_config directiva a directiva, auditd, fail2ban, gestió de pedaços com a procés, xifratge de disc, antivirus davant d'EDR, MDM, osquery per preguntar-li al parc, i automatització amb Ansible perquè la línia base no s'apliqui mai a mà.

Curs de Fonaments de Seguretat Informàtica

Mòdul 1: Introducció a la Seguretat Informàtica

Mòdul 2: Ciberseguretat

Mòdul 3: Criptografia

Mòdul 4: Gestió de Riscos i Mesures de Protecció

Mòdul 5: Eines i Tècniques de Seguretat

Mòdul 6: Bones Pràctiques i Normatives

Mòdul 7: Projecte Final

© Copyright 2026. Tots els drets reservats