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:
- 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”.
- 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:
- Stored (persistent): el payload se guarda (comentario, perfil) y se sirve a otros usuarios. El más peligroso: se ejecuta sin que la víctima haga nada raro.
- Reflected: el payload va en el request (query param) y se refleja en la respuesta inmediata. Requiere phishing de un link.
- DOM-based: el sink está en el JS del cliente (
innerHTML,eval,document.write) sin pasar por el server. El server nunca ve el payload; WAFs server-side no lo tocan.
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) # <script>...
# 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 validation | Output encoding | |
|---|---|---|
| Propósito | Rechazar/normalizar datos malformados | Neutralizar datos en un sink concreto |
| Cuándo | En el borde (al recibir) | En el punto de render/uso |
| Estrategia correcta | Allow-list (qué se permite), no deny-list | Contextual al sink (HTML/attr/JS/URL/SQL) |
| ¿Defensa de injection? | No por sí sola | Sí, 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
- Authentication (AuthN) = ¿quién sos? Verificar identidad (password + MFA, token válido, mTLS). Falla →
401 Unauthorized. - Authorization (AuthZ) = ¿qué podés tocar? Verificar permiso sobre este recurso específico. Falla →
403 Forbidden.
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:
- Mass assignment: dejar que el usuario setee
owner_id/rolevía el body (usa un schema de entrada distinto al de DB). - List endpoints que filtran en la UI pero devuelven todo en el payload. Testealo con una suite automática “user A no puede leer el objeto de user B”.
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”):
- Browser con server-rendered / sesión clásica → cookie
HttpOnly; Secure; SameSite. Defensa en profundidad:HttpOnlybloquea robo por XSS,SameSitebloquea CSRF. - API pura / mobile / SPA→API → access token bearer de vida corta (5–15 min) + refresh token. Guardá el refresh en cookie
HttpOnlyo secure storage del OS, no un JWT enlocalStorage(cualquier XSS lo lee).
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:
alg: none: token sin firma que algunas libs aceptan si no fijas el algoritmo.- Algorithm confusion (RS256→HS256): el atacante firma con HMAC usando la public key (que es pública) como secreto; si el server verifica con HS256, valida. Se evita fijando
algorithms=[...]explícito. - Revocación: un JWT es válido hasta
exppase lo que pase. Mitigás con expiry corto + refresh flow + deny-list porjtipara logout/ban inmediato. - El payload NO está encriptado, solo firmado (Base64). No metas datos sensibles en los claims.
RBAC / IAM — modelar quién puede qué
- RBAC (Role-Based): permisos agrupados en roles, usuarios asignados a roles. Simple, audita bien, escala hasta cierta granularidad. Trampa clásica: role explosion (un rol nuevo por cada combinación de permisos).
- ABAC (Attribute-Based): decisión por atributos (del usuario, del recurso, del entorno) evaluados por una policy. Más expresivo (p. ej. “puede editar si
resource.owner == user.id AND time in business_hours”), más difícil de auditar. - IAM / service accounts: para servicio-a-servicio no uses credenciales de usuario. Cada servicio tiene una identidad (IAM role, service account, workload identity) con least privilege — solo los permisos que ese servicio necesita, no una “god credential” compartida.
# 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):
- Secretos en variables de entorno inyectadas en runtime, nunca hardcodeados, nunca un
.envcommiteado (el clásico breach)..enven.gitignore+.env.examplesin valores. - Riesgos que asumís: las env vars filtran en crash dumps,
/proc/<pid>/environ, procesos hijo, logs que vuelcan el environment, y en la config de CI. Sin rotación centralizada ni audit de acceso. - Mitigaciones mínimas: least privilege por credencial (una por servicio, no una global), permisos de archivo estrictos, y un pre-commit hook / scanner (
gitleaks,detect-secrets) que bloquee secretos entrando a git.
# 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:
- Rotación centralizada sin redeploy y con audit log de cada acceso.
- Credenciales dinámicas / short-lived: Vault puede emitir una DB cred que expira en minutos; una filtración tiene ventana pequeña.
- Least privilege por identidad de workload: cada servicio se autentica con su IAM role / service account y solo ve sus secretos.
# 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:
- HSTS con
includeSubDomainsy preload — sabé que preload es difícil de deshacer, es un compromiso. - CSP es la de mayor valor pero la más difícil de rollout: despliega en
Content-Security-Policy-Report-Onlyprimero, mirá los reportes, luego enforce. Una CSP demasiado laxa (unsafe-inline) se ve compliant y no protege nada. SameSitemitiga CSRF a nivel browser pero no reemplaza tokens anti-CSRF si tenés un flujo cookie-based sensible (soporta strict/lax/none;noneexigeSecure).- CORS ≠ seguridad de servidor. CORS relaja la Same-Origin Policy para el browser; no protege tu API. Un
Access-Control-Allow-Origin: *no “abre” tu API a ataques server-side, pero combinado conAllow-Credentialsmal configurado sí filtra datos. No lo uses como control de acceso.
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; conincludeSubDomainsy, 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(oframe-ancestors 'none'en CSP) para clickjacking,Referrer-Policy.Secure+HttpOnly+SameSiteen 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?”
- ❌ Wrong / trap: “Yes, the ORM handles it.” Why it fails: ORMs parameterize the common path, but raw
.text(),.raw(), string-builtORDER BY/column/table names, andLIKEpatterns built by concatenation are NOT parameterized. Identifiers can’t be bound as parameters at all. A blanket “the ORM protects us” misses exactly the escape hatches attackers hit. - ✅ Correct: Parameterized queries are the real defense; the ORM gives them by default but any raw-SQL or dynamic-identifier path bypasses it.
- ⭐ Optimal (senior): Threat: SQLi is a code/data confusion bug — fix it by never letting data reach the parser as code. Safe default: bound parameters everywhere; for dynamic identifiers use a strict allow-list (map user input → known-safe column names), never interpolation. Trade-off: prepared statements can interact with connection poolers (e.g. PgBouncer transaction mode) — know your infra. Failure mode: second-order SQLi where stored data is later concatenated into a query — parameterize the read path too, not just the write.
Q2: “Design authentication for our public REST API. Cookies or JWT? Walk me through CSRF.”
- ❌ Wrong / trap: “JWT because it’s stateless and modern.” Why it fails: Naming a tech isn’t a design. It ignores revocation, token storage (localStorage → XSS-exfiltratable), and doesn’t explain why one option even has a CSRF problem.
- ✅ Correct: A bearer token in the
Authorizationheader isn’t sent automatically by the browser cross-site, so there’s no CSRF. Cookies are ambient credentials the browser attaches to every request to that origin, which is what makes CSRF possible; mitigate withSameSiteand/or anti-CSRF tokens. - ⭐ Optimal (senior): Decision by client: server-rendered/browser session →
HttpOnly; Secure; SameSitecookie (defense in depth:HttpOnlyblocks XSS token theft,SameSiteblocks CSRF). Pure API / mobile / SPA-to-API → short-lived bearer access token + refresh token, refresh stored in anHttpOnlycookie or secure storage. Trade-off: JWT buys horizontal scale (no session lookup) but revocation is the hard part — mitigate with short expiry (5–15 min) + a refresh flow + a server-side deny-list/jtiblacklist for forced logout. Never put a JWT inlocalStorageif you can avoid it (XSS reads it). Failure mode to name:alg: noneand algorithm-confusion (RS256 verified as HS256 using the public key as HMAC secret) — pin the algorithm on verify.
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?”
- ❌ Wrong / trap: “Make the IDs UUIDs / non-guessable.” Why it fails: That’s security-through-obscurity. IDs leak (emails, referrers, logs). The real bug is missing authorization, not guessable identifiers.
- ✅ Correct: This is broken object-level authorization (IDOR / BOLA). Every request must verify the authenticated principal is allowed to access that specific object, not just that they’re logged in.
- ⭐ Optimal (senior): Distinguish AuthN (who you are) from AuthZ (what you may touch). Safe default: enforce ownership/tenant checks in a central layer (query scoped by
tenant_id/owner_id, or a policy check), so it can’t be forgotten per-endpoint. Trade-off: per-route checks are explicit but easy to omit; centralized policy (RBAC/ABAC, row-level filters) is safer but needs discipline to not have bypass paths. Failure modes: mass-assignment letting a user setowner_id, and list endpoints that filter in the UI but return everything in the payload. Test it with an automated “user A cannot read user B’s object” suite.
Q4: “Where do the database password and third-party API keys live in production?”
- ❌ Wrong / trap: “In environment variables / a
.envfile on the server.” Why it fails: Env vars leak into crash dumps, child processes,/proc, logging of the environment, and CI. A.envin the repo is the classic breach. No rotation story, no least privilege. - ✅ Correct: In a secrets manager (Vault, AWS Secrets Manager) — never in git, never hardcoded. The app fetches at boot or reads an injected mount; secrets are rotatable and access-controlled.
- ⭐ Optimal (senior): Why a manager over env vars: centralized rotation, audit log of access, dynamic/short-lived credentials (e.g. Vault issues a DB cred that expires), and least-privilege scoping per service identity (IAM role / service account) instead of one god credential. Trade-off: a secrets manager adds a runtime dependency and bootstrap-trust problem (how does the app authenticate to the manager? → workload identity, not another static secret). Failure modes to name: secret sprawl in CI logs, and rotation that breaks in-flight connections — do dual-secret / overlap rotation. And: a leaked secret in git history stays there after deletion — rotate it, don’t just
git rm.
Q5: “We’re on HTTPS. Are we done with transport security?”
- ❌ Wrong / trap: “Yes, TLS encrypts everything.” Why it fails: HTTPS on the endpoint doesn’t stop an SSL-strip / first-request downgrade, mixed content, or a user hitting
http://. TLS alone says nothing about MIME sniffing, framing, or cookie scope. - ✅ Correct: Add HSTS (
Strict-Transport-Security) so the browser refuses plaintext after the first visit, redirect all HTTP→HTTPS, and set the security headers (X-Content-Type-Options: nosniff, CSP/frame-ancestors) plusSecureon cookies. - ⭐ Optimal (senior): HSTS with
includeSubDomainsand, ideally, preload (know that preload is hard to undo — it’s a commitment). CSP is the high-value header (kills most XSS injection sinks) but is the hardest to roll out — deploy inReport-Onlyfirst, then enforce.X-Frame-Options/frame-ancestorsfor clickjacking. Trade-off: strict CSP breaks inline scripts and third-party widgets — you pay in engineering effort for a real reduction in XSS blast radius. Failure mode: a too-broad CSP (unsafe-inline) that looks compliant but protects nothing.
Q6: “What do you log by default, and what must never be logged?”
- ❌ Wrong / trap: “Log the full request/response for debugging.” Why it fails: That captures
Authorizationheaders, session cookies, passwords in bodies, and PII — turning your log store (often broadly readable, shipped to third parties) into a breach waiting to happen and a compliance violation. - ✅ Correct: Log structured metadata — request id, method, path, status, latency, user id, source — and never credentials, tokens, passwords, or raw PII. Redact sensitive fields before they reach the log.
- ⭐ Optimal (senior): Use an allow-list of loggable fields + a central redaction layer, not per-call discipline (people forget). Threat: logs are a soft target — often less protected than the DB, and replicated to observability vendors. Safe defaults: hash/tokenize user identifiers where you can still correlate, truncate IPs, and keep secrets out of exception messages (don’t log the object that holds the token). Trade-off: aggressive redaction hurts debuggability — mitigate with correlation IDs so you can trace without storing the sensitive payload. Failure mode: secrets leaking via stack traces and query logs that echo bound parameters.
Q7: “XSS — is validating input on the way in enough to prevent it?”
- ❌ Wrong / trap: “Yes, we sanitize/strip
<script>on input.” Why it fails: Input sanitization is a deny-list that misses encodings, event handlers (onerror=),javascript:URLs, and DOM-based sinks the server never sees. And the same stored value can be safe in one context and dangerous in another. - ✅ Correct: The real defense is contextual output encoding at the render point — HTML body, HTML attribute, JS, and URL each need a different encoding. Input validation is hygiene, not the XSS defense.
- ⭐ Optimal (senior): Encode per sink (
html.escapefor HTML,json.dumpsinside<script>, percent-encoding for URLs), rely on template autoescaping and treat|safe/Markupas a code smell over user input. Structural kill switch: a strict CSP (script-src 'self', nounsafe-inline) so an injected script won’t execute even if it reaches the DOM. Distinguish stored vs reflected vs DOM-based (WAFs don’t see DOM-based). For a JSON API,Content-Type: application/json+nosniffcuts the reflected surface, but any value that lands in a page still gets encoded.
Q8: “Difference between authentication and authorization, and where does RBAC fit?”
- ❌ Wrong / trap: “They’re basically the same — logging in.” Why it fails: Conflating them is the root cause of BOLA/IDOR: you check the user is logged in (AuthN) and forget to check they may touch this object (AuthZ).
- ✅ Correct: AuthN = who you are (identity, → 401 on failure). AuthZ = what you may do/touch (permission, → 403). RBAC is one AuthZ model: permissions grouped into roles, users assigned roles.
- ⭐ Optimal (senior): RBAC answers “may this class of user do this action” — it does NOT replace object-level checks. An
editorcan edit posts in general yet must not edit another tenant’s post, so you need role check (action-level AuthZ) and ownership/tenant check (object-level AuthZ), enforced centrally. Trade-off: RBAC is simple and auditable but suffers role explosion at fine granularity; ABAC (attributes/policies) is more expressive but harder to audit. For service-to-service, use IAM roles / service accounts with least privilege, not shared user credentials.
Referencias
- OWASP Top 10 (web): https://owasp.org/www-project-top-ten/
- OWASP API Security Top 10 (BOLA/IDOR #1): https://owasp.org/API-Security/
- OWASP Cheat Sheets (XSS Prevention, DOM XSS, CSRF, SQLi, JWT, Secrets Management, HSTS, Content Security Policy): https://cheatsheetseries.owasp.org/
- OWASP ASVS (Application Security Verification Standard): https://owasp.org/www-project-application-security-verification-standard/
- MDN — HTTP security headers / CSP / SameSite cookies: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers
- PyJWT (algorithm pinning,
alg:none): https://pyjwt.readthedocs.io/ - Relacionado: HTTP Request Lifecycle.