Atlas

Security OWASP para Web APIs

Elimina clases enteras de bugs por diseño (parameterized queries, output encoding contextual, tokens sin cookie ambient, allow-lists), no parchees síntomas con un WAF. La defensa correcta vive en la capa correcta.

Teoría

Modelo mental: confusión código/dato y credencial ambient

Casi toda la superficie de OWASP colapsa en dos primitivas que un senior debe poder nombrar en una entrevista:

  1. Confusión código/dato (injection). SQLi, XSS, command injection, template injection y LDAP injection son la misma falla: input controlado por el atacante llega a un intérprete (el parser SQL, el motor HTML/JS del browser, el shell) y se ejecuta como código en vez de tratarse como dato inerte. La cura estructural es siempre la misma forma: separar el canal de código del canal de datos (parámetros ligados, encoding contextual en el sink). No es “sanitizar”, es “nunca dejar que el dato alcance el parser como código”.
  2. Credencial ambient (CSRF). El browser adjunta cookies automáticamente a cualquier request hacia el origin, sin que el JS del atacante tenga que leerlas. Esa adjunción implícita es lo que hace posible CSRF. Mover la credencial a un lugar que el browser no envía solo (header Authorization) elimina la clase.

La postura senior no es “meter una librería” sino elegir defensas de costo fijo bajo que no dependen de que cada dev recuerde hacer lo correcto en cada sink nuevo. Una allow-list o un parámetro ligado protege todos los casos futuros; una sanitización manual por deny-list falla en el primer sink que alguien olvide.

Trade-off transversal: seguridad vs fricción de dev/UX, y prevención estructural vs parche puntual. Segundo eje que aparece en todo diseño de auth: stateless tokens (JWT) compran escala horizontal pero pagan con revocación difícil; sessions server-side dan revocación instantánea pero exigen un shared store. No hay opción gratis: eliges qué failure mode toleras.

SQL Injection — la única defensa correcta es parameterización

# ── SQLi: input del atacante se convierte en código SQL ──
# ❌ f-string / concat: fatal
email = "x' OR '1'='1"
cur.execute(f"SELECT * FROM users WHERE email = '{email}'")
#   → WHERE email = 'x' OR '1'='1'  → dumpea toda la tabla
#   con "'; DROP TABLE users; --"   → destrucción

# ✅ parameterized query: driver manda query y datos por canales SEPARADOS.
#    El input jamás se parsea como SQL. Mata la clase entera, no un caso.
cur.execute("SELECT * FROM users WHERE email = %s", (email,))     # psycopg
cur.execute("SELECT * FROM users WHERE email = ?", (email,))      # sqlite/otros

# ✅ ORM (SQLAlchemy) parameteriza por default
session.execute(select(User).where(User.email == email))

# ✅ raw SQL vía ORM: usa bind params con nombre, NUNCA f-string
from sqlalchemy import text
session.execute(text("SELECT * FROM users WHERE email = :email"), {"email": email})

Los agujeros que un senior debe conocer porque el ORM NO los cubre:

# ── Identificadores NO se pueden ligar como parámetros ──
# Column names, table names, ORDER BY, ASC/DESC no aceptan bind params.
# ❌ VULNERABLE aunque "uses el ORM":
session.execute(text(f"SELECT * FROM users ORDER BY {sort_col} {direction}"))

# ✅ allow-list: mapea input externo → identificador conocido-seguro
SORT_COLUMNS = {"name": "name", "created": "created_at", "email": "email"}
DIRECTIONS = {"asc": "ASC", "desc": "DESC"}
col = SORT_COLUMNS.get(sort_col, "created_at")   # default seguro si no matchea
dir_ = DIRECTIONS.get(direction, "ASC")
session.execute(text(f"SELECT * FROM users ORDER BY {col} {dir_}"))  # ahora es seguro

# ── LIKE construido por concatenación reintroduce el hueco ──
# ✅ liga el patrón como parámetro; escapa % y _ si son literales del usuario
pattern = f"%{term}%"
session.execute(text("SELECT * FROM users WHERE name LIKE :p"), {"p": pattern})

# ── Second-order SQLi: dato almacenado limpio, concatenado luego ──
# Parameteriza también el READ path, no solo el WRITE. El input "de la DB"
# no es más confiable que el input "del request" si originalmente vino de un user.

Nota de infra (senior): los prepared statements pueden chocar con connection poolers en transaction mode (p. ej. PgBouncer): en ese modo no persisten prepared statements entre transacciones. Conocé tu pooler; drivers modernos (psycopg3) manejan el caso, pero es un failure mode real que hay que poder nombrar.

XSS — input validation NO basta; la defensa es output encoding contextual

XSS es confusión código/dato en el browser: dato del atacante termina interpretándose como HTML/JS. Hay tres tipos y un senior los distingue:

La regla clave: la validación de input NO es la defensa de XSS. Input validation reduce ruido, pero la cura es output encoding en el punto de render, contextual según el sink:

# ── Encoding contextual: cada sink tiene su encoding, no hay uno universal ──
import html
import json
from urllib.parse import quote

user = '<script>alert(document.cookie)</script>'

# 1) HTML body        → escapa < > & " '
html.escape(user)                    # &lt;script&gt;...

# 2) HTML attribute   → además de escapar, SIEMPRE entre comillas
f'<input value="{html.escape(user, quote=True)}">'

# 3) Dentro de <script> / JS context → json.dumps, no html.escape
f'<script>const u = {json.dumps(user)};</script>'   # comillas y </script> seguros

# 4) URL / query param → percent-encoding
f'https://x/search?q={quote(user)}'

En templates, el motor debe autoescapar por default:

# Jinja2: activa autoescape (Flask/FastAPI+Jinja lo hacen para .html)
from jinja2 import Environment, select_autoescape
env = Environment(autoescape=select_autoescape(["html", "xml"]))
# ⚠️ el filtro |safe y markupsafe.Markup(...) DESACTIVAN el escape:
#    úsalos solo sobre contenido ya saneado con un sanitizer HTML real
#    (nh3/bleach), nunca sobre input crudo del usuario.

API JSON pura: el riesgo de XSS baja fuerte si respondes Content-Type: application/json + X-Content-Type-Options: nosniff (impide que el browser “adivine” que es HTML y lo ejecute). Pero cualquier dato que termine en una página HTML se encoda al renderizar, sin excepción. Y ojo con endpoints que devuelven HTML “de error” o reflejan el Origin.

Header que mata la clase de raíz: Content-Security-Policy. Una CSP estricta (script-src 'self' sin unsafe-inline) hace que aunque un <script> inyectado llegue al DOM, el browser se niegue a ejecutarlo. Es la defensa de mayor valor y la más difícil de desplegar (ver TLS/headers abajo).

Input validation vs output encoding — no son intercambiables

Input validationOutput encoding
PropósitoRechazar/normalizar datos malformadosNeutralizar datos en un sink concreto
CuándoEn el borde (al recibir)En el punto de render/uso
Estrategia correctaAllow-list (qué se permite), no deny-listContextual al sink (HTML/attr/JS/URL/SQL)
¿Defensa de injection?No por sí solaSí, es la defensa real

Validación con Pydantic en el borde (allow-list por tipo y forma):

from pydantic import BaseModel, EmailStr, Field, field_validator

class CreateUser(BaseModel):
    email: EmailStr
    age: int = Field(ge=0, le=130)
    username: str = Field(min_length=3, max_length=32, pattern=r"^[a-zA-Z0-9_]+$")
    # allow-list explícita para enums en vez de aceptar cualquier string
    role: str = Field(default="user")

    @field_validator("role")
    @classmethod
    def role_allowed(cls, v: str) -> str:
        if v not in {"user", "editor"}:      # NUNCA permitas "admin" desde input
            raise ValueError("invalid role")
        return v

Pydantic da validación de forma y tipo, pero no reemplaza AuthZ ni output encoding: un email válido sigue necesitando parameterización al ir a SQL y encoding al ir a HTML. La validación es higiene, no defensa de injection.

AuthN vs AuthZ — separá los dos ejes

El bug más común y más caro es Broken Object Level Authorization (BOLA/IDOR) — el #1 del OWASP API Security Top 10: el endpoint verifica que estás logueado (AuthN) pero no que ese objeto sea tuyo (AuthZ).

# ❌ BOLA/IDOR: cualquiera logueado lee la factura de cualquiera
@app.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: int, user=Depends(current_user)):
    return await db.get(Invoice, invoice_id)   # falta el ownership check

# ✅ scope la query por el principal — no un check aparte que se pueda olvidar
@app.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: int, user=Depends(current_user)):
    inv = await db.scalar(
        select(Invoice).where(
            Invoice.id == invoice_id,
            Invoice.owner_id == user.id,      # ownership/tenant en el WHERE
        )
    )
    if inv is None:
        raise HTTPException(404)              # 404, no 403: no revela existencia
    return inv

Regla estructural: aplica el filtro de tenant/owner en una capa central (dependency, query scoping, row-level security en Postgres), no repartido por endpoint donde es fácil omitirlo. Dos failure modes que un senior nombra:

Session vs Token — el eje de decisión

┌─ Session (server-side) ──────────────┐   ┌─ Token / JWT (stateless) ─────────┐
│ Cookie con session_id opaco          │   │ Bearer token firmado con claims   │
│ Estado en Redis/DB (shared store)    │   │ Sin lookup: verificas la firma    │
│ Revocación: borrar la row → instant  │   │ Revocación: DIFÍCIL (ya es válido │
│ Escala: necesita store compartido    │   │   hasta exp) → deny-list por jti  │
│ CSRF: SÍ (cookie ambient) → SameSite │   │ CSRF: NO si va en Authorization   │
│ Robo: HttpOnly bloquea lectura JS    │   │ XSS: localStorage es exfiltrable  │
└──────────────────────────────────────┘   └───────────────────────────────────┘

Decisión por tipo de cliente (respuesta senior, no “JWT porque es moderno”):

JWT correcto en FastAPI, con los failure modes cubiertos:

from datetime import datetime, timedelta, timezone
import jwt   # PyJWT
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

ALGORITHM = "HS256"          # ⚠️ FIJA el algoritmo; ver algorithm confusion abajo
ACCESS_TTL = timedelta(minutes=15)

def create_access_token(user_id: str, role: str, secret: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": user_id,
        "role": role,
        "iat": now,
        "exp": now + ACCESS_TTL,
        "jti": _new_jti(),        # id único → permite deny-list para logout forzado
        "type": "access",
    }
    return jwt.encode(payload, secret, algorithm=ALGORITHM)

bearer = HTTPBearer()

async def current_user(cred: HTTPAuthorizationCredentials = Depends(bearer),
                       secret: str = Depends(get_secret)) -> dict:
    try:
        payload = jwt.decode(
            cred.credentials,
            secret,
            algorithms=[ALGORITHM],   # lista EXPLÍCITA → bloquea alg:none y RS/HS confusion
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(401, "invalid token")
    if payload.get("type") != "access":
        raise HTTPException(401, "wrong token type")
    if await is_revoked(payload["jti"]):     # deny-list para revocación real
        raise HTTPException(401, "revoked")
    return {"user_id": payload["sub"], "role": payload["role"]}

Failure modes de JWT que un senior debe poder nombrar:

RBAC / IAM — modelar quién puede qué

# RBAC declarativo con dependency reutilizable (FastAPI)
from fastapi import Depends, HTTPException

def require_role(*allowed: str):
    async def checker(user: dict = Depends(current_user)) -> dict:
        if user["role"] not in allowed:
            raise HTTPException(403, "insufficient permissions")  # 403, no 401
        return user
    return checker

@app.delete("/users/{uid}")
async def delete_user(uid: str, admin=Depends(require_role("admin"))):
    ...

Regla: RBAC (rol) responde “¿puede esta clase de usuario hacer esta acción?”; NO reemplaza el object-level check (BOLA). Un editor puede editar posts en general y aun así no debe editar el post de otro tenant. Necesitas ambos: role check (AuthZ de acción) + ownership check (AuthZ de objeto).

Secrets — storage y rotation, con y sin manager

Sin secrets manager (aceptable para proyectos chicos, con disciplina):

# Sin manager: leer de env, fallar ruidoso si falta (no default inseguro)
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str          # sin default → arranca solo si está seteado
    jwt_secret: str
    class Config:
        env_file = ".env"      # local dev; en prod, env inyectada por el orquestador

settings = Settings()          # ValidationError al boot si falta algo → fail fast

Con secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager, cloud KMS) — el default en producción seria. Qué compra sobre env vars:

# Con manager: fetch al boot (o mount inyectado). El secreto no vive en el repo.
import boto3, json

def load_db_secret(secret_id: str) -> dict:
    client = boto3.client("secretsmanager")
    resp = client.get_secret_value(SecretId=secret_id)
    return json.loads(resp["SecretString"])   # {"username": ..., "password": ...}

El problema del bootstrap-trust (la pregunta senior): ¿cómo se autentica la app al manager sin otro secreto estático? Respuesta: workload identity — el IAM role del pod/VM/función, no una API key hardcodeada. Si tu solución al secret problem es otro secret, no resolviste nada.

Rotación sin downtime: rotación con overlap / dual-secret — el servicio acepta el secreto viejo y el nuevo durante una ventana, luego se retira el viejo. Failure mode: rotación que corta conexiones in-flight (pool con la password vieja). Y crítico: un secreto filtrado en el historial de git sigue ahí tras borrarlo del working tree — hay que ROTARLO, no solo git rm. Otro failure mode: secret sprawl en logs de CI.

TLS, secure headers y cookies — el edge

HTTPS en el endpoint no cierra transporte por sí solo: no frena un SSL-strip / downgrade en el primer request, ni mixed content, ni un usuario que teclea http://. TLS tampoco dice nada de MIME sniffing, framing ni scope de cookies.

# ── Secure headers como middleware (FastAPI / Starlette) ──
from fastapi import FastAPI, Response
app = FastAPI()

@app.middleware("http")
async def security_headers(request, call_next):
    resp: Response = await call_next(request)
    # HSTS: el browser se niega a hablar plaintext tras la 1ra visita
    resp.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains; preload"
    resp.headers["X-Content-Type-Options"] = "nosniff"      # sin MIME sniffing
    resp.headers["X-Frame-Options"] = "DENY"                # anti-clickjacking (legacy)
    # CSP: el header de mayor valor — mata la mayoría de sinks de XSS
    resp.headers["Content-Security-Policy"] = (
        "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"
    )
    resp.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    return resp
# ── Cookie de sesión: los tres flags NO son opcionales ──
resp.set_cookie(
    "session", token,
    httponly=True,       # JS no la lee → mitiga robo por XSS
    secure=True,         # solo sobre HTTPS
    samesite="strict",   # o "lax": mitiga CSRF a nivel browser
    max_age=3600,
    path="/",
)

Puntos senior:

Logging — qué sí y qué nunca

Loguea metadata estructurada: request_id, método, path, status, latencia, user_id, source/IP truncada. Nunca loguees headers Authorization/Cookie, passwords, tokens, PANs ni PII cruda.

Usá una allow-list de campos loggeables + un redactor central, no disciplina por-call (la gente olvida). Los logs son un soft target: suelen estar menos protegidos que la DB y se replican a vendors de observabilidad. Failure modes: secretos filtrando por stack traces (no loguees el objeto que contiene el token), y query logs que ecoan bound parameters. Mitigá el trade-off “redacción vs debuggability” con correlation IDs: trazás sin almacenar el payload sensible.

Ejercicios

1. Cerrar el hueco de SQLi que el ORM no cubre. Tenés un endpoint de listado con ?sort=<col>&dir=<asc|desc> que hoy hace text(f"... ORDER BY {sort} {dir}"). Es vulnerable pese a usar SQLAlchemy. Explicá por qué los parámetros ligados no resuelven este caso y reescribilo seguro.

Solución

Los bind parameters solo aplican a valores (el lado derecho de una comparación), no a identificadores (nombres de columna, tabla, dirección de orden) ni a palabras clave SQL. ORDER BY :col no funciona: el driver lo trataría como el valor string 'col', no como la columna. Por eso el f-string sobre sort/dir sigue siendo inyectable.

La defensa es una allow-list: mapear el input externo a un conjunto cerrado de identificadores conocidos-seguros.

SORT_COLUMNS = {"name": "name", "created": "created_at", "email": "email"}
DIRECTIONS = {"asc": "ASC", "desc": "DESC"}

def build_order(sort: str, direction: str) -> str:
    col = SORT_COLUMNS.get(sort, "created_at")      # default seguro
    dir_ = DIRECTIONS.get(direction.lower(), "ASC")
    return f"ORDER BY {col} {dir_}"                 # ahora TODO viene de la allow-list

# Mejor aún: usa la API tipada del ORM, que no toca strings crudos
from sqlalchemy import asc, desc
col_attr = {"name": User.name, "created": User.created_at}.get(sort, User.created_at)
order = desc(col_attr) if direction.lower() == "desc" else asc(col_attr)
stmt = select(User).order_by(order)

Clave: el input del usuario nunca toca el SQL directamente; solo selecciona cuál de N opciones pre-aprobadas se usa.

2. Diseñar auth para una API pública y justificar CSRF. Te piden diseñar authentication para una REST API pública consumida por una SPA y una app mobile. Elegí el modelo de credencial, explicá por qué tu elección no tiene problema de CSRF, y listá cómo resolvés revocación.

Solución

Elección: access token bearer de vida corta (5–15 min) en el header Authorization: Bearer <token> + refresh token de vida larga. El refresh se guarda en cookie HttpOnly; Secure; SameSite=Strict (para la SPA) o en secure storage del OS (mobile). El access token no va a localStorage.

Por qué no hay CSRF: CSRF explota que el browser adjunta automáticamente las credenciales ambient (cookies) a cualquier request cross-site. Un bearer token en el header Authorization no se envía solo: el JS de un origin atacante no puede leerlo (Same-Origin Policy) ni setear ese header en una request cross-site que dispare con las credenciales de la víctima. Sin adjunción implícita, no hay CSRF. (El refresh sí va en cookie, por eso SameSite=Strict + endpoint de refresh que valida origin.)

Revocación (el punto débil de JWT): expiry corto en el access token (la ventana de un token filtrado es minutos) + refresh flow + una deny-list por jti en un store rápido (Redis) para logout forzado/ban inmediato. Al revocar, se invalida el refresh y se agrega el jti del access a la deny-list hasta su exp.

Failure modes que menciono sin que me pregunten: fijar algorithms=[...] en el decode (bloquea alg:none y RS256→HS256 confusion); nunca JWT en localStorage (XSS lo exfiltra); el payload es Base64, no encriptado.

3. Encontrar y arreglar la clase de bug de IDOR. Usuarios reportan que cambiando el ID en /orders/{id} ven pedidos ajenos. Un compañero propone “hacer los IDs UUIDs para que no se puedan adivinar”. Explicá por qué no alcanza y arreglá la clase de bug.

Solución

UUIDs son security through obscurity: los IDs filtran por emails, referrers, logs, respuestas de API. No adivinar el ID no impide usarlo una vez conocido. El bug real no es “IDs adivinables”, es authorization ausente a nivel de objeto (BOLA/IDOR): el endpoint verifica AuthN (estás logueado) pero no AuthZ de objeto (ese pedido es tuyo).

Fix estructural: cada request verifica que el principal autenticado puede acceder a ese objeto específico, scopeando la query por owner/tenant en una capa central:

@app.get("/orders/{order_id}")
async def get_order(order_id: int, user=Depends(current_user)):
    order = await db.scalar(
        select(Order).where(Order.id == order_id, Order.owner_id == user.id)
    )
    if order is None:
        raise HTTPException(404)   # 404 en vez de 403: no confirma existencia
    return order

Mejor que un check por-endpoint (fácil de olvidar): filtro central por tenant (dependency, o row-level security en Postgres). Y una suite de test automática “user A no puede leer el objeto de user B” que corra en CI. Failure modes relacionados: mass-assignment (que el user setee owner_id vía body — usá un input schema distinto) y list endpoints que filtran en UI pero devuelven todo.

4. Migrar secretos de .env a un manager y diseñar la rotación. Producción tiene la DB password y API keys de terceros en un .env en el servidor. Enumerá los riesgos concretos, describí la migración a un secrets manager y cómo rotás sin downtime.

Solución

Riesgos del .env / env vars: filtran en crash dumps, /proc/<pid>/environ, procesos hijo, logs que vuelcan el environment y config de CI; un .env que se cuela a git es un breach permanente; sin rotación centralizada ni audit de acceso; típicamente una credencial compartida sin least privilege.

Migración: mover los secretos a un manager (Vault / AWS Secrets Manager / GCP Secret Manager). La app los fetchea al boot o lee un mount inyectado. Bootstrap-trust: la app se autentica al manager con workload identity (IAM role del pod/VM, service account) — nunca otra API key estática; si tu solución al secret problem es otro secret, no resolviste nada. Least privilege: cada servicio ve solo sus secretos vía su identidad.

Rotación sin downtime: dual-secret / overlap — el servicio acepta credencial vieja y nueva durante una ventana, luego se retira la vieja; así no cortás conexiones in-flight del pool. Con Vault, ideal: credenciales dinámicas short-lived (la DB cred expira en minutos, la filtración tiene ventana chica). Crítico: los secretos que ya estuvieron en .env/git se rotan, no basta borrarlos del working tree — siguen en el historial. Extra: scanner de secretos (gitleaks) en pre-commit y CI para prevenir reincidencia.

5. Endurecer el edge: de “estamos en HTTPS” a transporte y headers completos. Un servicio sirve por HTTPS y el equipo lo da por cerrado. Listá qué falta y en qué orden desplegás CSP para no romper la app.

Solución

HTTPS en el endpoint no cubre: SSL-strip/downgrade en el primer request, usuarios tecleando http://, MIME sniffing, clickjacking, scope de cookies. Falta:

  • HSTS (Strict-Transport-Security) para que el browser rechace plaintext tras la primera visita; con includeSubDomains y, si te comprometés, preload (difícil de deshacer). Más redirect HTTP→HTTPS en el edge.
  • X-Content-Type-Options: nosniff, X-Frame-Options: DENY (o frame-ancestors 'none' en CSP) para clickjacking, Referrer-Policy.
  • Secure + HttpOnly + SameSite en cookies.
  • CSP, el header de mayor valor: mata la mayoría de sinks de XSS.

Rollout de CSP sin romper: desplegar primero como Content-Security-Policy-Report-Only con un endpoint de reporte; recolectar violaciones reales (inline scripts, widgets de terceros) durante días; ajustar la policy (mover inline scripts a archivos, agregar nonces/hashes en vez de unsafe-inline); recién entonces cambiar a Content-Security-Policy (enforce). Anti-patrón: meter unsafe-inline para “que compile” — se ve compliant y no protege nada.

Preguntas tipo entrevista (EN)

Q1: “Our ORM already escapes everything, so we’re safe from SQL injection. True?”

Q2: “Design authentication for our public REST API. Cookies or JWT? Walk me through CSRF.”

Q3: “Users report they can see other users’ invoices by changing the ID in the URL. What happened and how do you fix the class of bug?”

Q4: “Where do the database password and third-party API keys live in production?”

Q5: “We’re on HTTPS. Are we done with transport security?”

Q6: “What do you log by default, and what must never be logged?”

Q7: “XSS — is validating input on the way in enough to prevent it?”

Q8: “Difference between authentication and authorization, and where does RBAC fit?”

Referencias