Módulo 5: Patrones de Error Comunes
Ejercicio: Identificar Patrones de Error
Ejercicio: Identificar Patrones de Error
Descripción de la cápsula
Esta cápsula es tu campo de práctica. En las cápsulas 02, 03 y 04 aprendiste a reconocer tres categorías de patrones de error: naming y abstracciones incorrectas, edge cases no manejados, y security holes. Ahora vas a aplicar todo junto en un escenario realista.
A continuación encontrarás una aplicación FastAPI completa — un API de gestión de notas con usuarios. El código funciona parcialmente: si lo ejecutas, algunos endpoints responden correctamente. Pero tiene 5 patrones de error embebidos que representan las tres categorías que estudiaste. Tu trabajo es:
- Identificar cada patrón de error
- Explicar por qué es un problema (no basta con señalar — justifica)
- Proporcionar la corrección
Los errores no son obvios. Este código podría pasar un code review superficial. Los errores están en código que "se ve bien" — exactamente como el código que AI genera en la práctica.
Instrucciones
Cómo abordar el ejercicio
Paso 1: Lee el código completo una vez sin buscar errores
→ Entiende qué hace la aplicación
Paso 2: Relee aplicando los patrones del módulo
→ Para cada función, pregúntate:
- ¿El nombre refleja lo que realmente hace?
- ¿Qué pasa con inputs vacíos/nulos/extremos?
- ¿Hay alguna vulnerabilidad de seguridad?
Paso 3: Documenta cada error que encuentres
→ Para cada uno: ubicación, categoría, impacto, corrección
Paso 4: Compara con las soluciones
→ ¿Encontraste los 5? ¿Tus correcciones son correctas?
Criterio de éxito
5 de 5 errores encontrados → Excelente. Pattern recognition sólido.
4 de 5 errores encontrados → Muy bien. Revisa cuál se escapó y por qué.
3 de 5 errores encontrados → Bien. Vuelve a leer las cápsulas 02-04.
2 o menos → Necesitas más práctica. Repasa los patrones.
Formato de tus respuestas
Para cada error que identifiques, usa este formato:
Error #N:
- Ubicación: [línea o función]
- Categoría: [naming | edge case | security]
- Descripción: [qué está mal]
- Impacto: [qué puede pasar]
- Corrección: [código corregido]
La Aplicación: NotesAPI
Esta es una aplicación FastAPI para gestionar notas personales con usuarios. Lee el código completo antes de buscar errores.
"""
NotesAPI — API de gestión de notas personales.
Funcionalidades: registro de usuarios, login, CRUD de notas, búsqueda.
"""
from fastapi import FastAPI, HTTPException, Query, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field
from datetime import datetime, timedelta
from jose import jwt, JWTError
import sqlite3
import hashlib
app = FastAPI(title="NotesAPI", version="1.0.0")
security = HTTPBearer()
# --- Configuración ---
JWT_SECRET = "notes-api-secret-key-2024-production"
JWT_ALGORITHM = "HS256"
DB_PATH = "notes.db"
# --- Modelos ---
class UserRegister(BaseModel):
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=6)
email: str
class UserLogin(BaseModel):
username: str
password: str
class NoteCreate(BaseModel):
title: str = Field(max_length=200)
content: str
tags: list[str] = []
class NoteUpdate(BaseModel):
title: str | None = None
content: str | None = None
tags: list[str] | None = None
# --- Base de datos ---
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
""")
conn.commit()
conn.close()
init_db()
# --- Utilidades ---
def hash_password(password: str) -> str:
return hashlib.md5(password.encode()).hexdigest()
def create_token(user_id: int, username: str) -> str:
payload = {
"sub": str(user_id),
"username": username,
"exp": datetime.utcnow() + timedelta(hours=24),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
try:
payload = jwt.decode(
credentials.credentials, JWT_SECRET, algorithms=[JWT_ALGORITHM]
)
user_id = int(payload["sub"])
username = payload["username"]
except (JWTError, KeyError, ValueError):
raise HTTPException(status_code=401, detail="Invalid token")
conn = get_db()
user = conn.execute(
"SELECT id, username, email FROM users WHERE id = ?", (user_id,)
).fetchone()
conn.close()
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return dict(user)
# --- Endpoints de autenticación ---
@app.post("/auth/register")
async def register(user: UserRegister) -> dict:
conn = get_db()
existing = conn.execute(
"SELECT id FROM users WHERE username = ?", (user.username,)
).fetchone()
if existing:
conn.close()
raise HTTPException(status_code=409, detail="Username already exists")
password_hash = hash_password(user.password)
cursor = conn.execute(
"INSERT INTO users (username, password_hash, email) VALUES (?, ?, ?)",
(user.username, password_hash, user.email),
)
conn.commit()
user_id = cursor.lastrowid
conn.close()
token = create_token(user_id, user.username)
return {"user_id": user_id, "token": token}
@app.post("/auth/login")
async def login(credentials: UserLogin) -> dict:
conn = get_db()
user = conn.execute(
"SELECT id, username, password_hash FROM users WHERE username = ?",
(credentials.username,),
).fetchone()
conn.close()
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
if user["password_hash"] != hash_password(credentials.password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_token(user["id"], user["username"])
return {"user_id": user["id"], "token": token}
# --- Endpoints de notas ---
@app.post("/notes")
async def create_note(
note: NoteCreate,
current_user: dict = Depends(get_current_user),
) -> dict:
conn = get_db()
tags_str = ",".join(note.tags)
cursor = conn.execute(
"INSERT INTO notes (user_id, title, content, tags) VALUES (?, ?, ?, ?)",
(current_user["id"], note.title, note.content, tags_str),
)
conn.commit()
note_id = cursor.lastrowid
conn.close()
return {
"id": note_id,
"title": note.title,
"content": note.content,
"tags": note.tags,
"created_at": datetime.now().isoformat(),
}
@app.get("/notes")
async def get_user_notes(
current_user: dict = Depends(get_current_user),
page: int = Query(default=1),
size: int = Query(default=10),
) -> dict:
conn = get_db()
total = conn.execute(
"SELECT COUNT(*) as count FROM notes WHERE user_id = ?",
(current_user["id"],),
).fetchone()["count"]
offset = (page - 1) * size
total_pages = total // size
notes = conn.execute(
"SELECT id, title, content, tags, created_at, updated_at FROM notes WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
(current_user["id"], size, offset),
).fetchall()
conn.close()
return {
"notes": [dict(n) for n in notes],
"page": page,
"total_pages": total_pages,
"total_items": total,
}
@app.get("/notes/{note_id}")
async def get_note(
note_id: int,
current_user: dict = Depends(get_current_user),
) -> dict:
conn = get_db()
note = conn.execute(
"SELECT id, user_id, title, content, tags, created_at, updated_at FROM notes WHERE id = ?",
(note_id,),
).fetchone()
conn.close()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
return dict(note)
@app.put("/notes/{note_id}")
async def update_note(
note_id: int,
update: NoteUpdate,
current_user: dict = Depends(get_current_user),
) -> dict:
conn = get_db()
note = conn.execute(
"SELECT * FROM notes WHERE id = ?", (note_id,)
).fetchone()
if not note:
conn.close()
raise HTTPException(status_code=404, detail="Note not found")
updates = {}
if update.title is not None:
updates["title"] = update.title
if update.content is not None:
updates["content"] = update.content
if update.tags is not None:
updates["tags"] = ",".join(update.tags)
if updates:
updates["updated_at"] = datetime.now().isoformat()
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [note_id]
conn.execute(f"UPDATE notes SET {set_clause} WHERE id = ?", values)
conn.commit()
updated_note = conn.execute(
"SELECT * FROM notes WHERE id = ?", (note_id,)
).fetchone()
conn.close()
return dict(updated_note)
@app.delete("/notes/{note_id}")
async def delete_note(
note_id: int,
current_user: dict = Depends(get_current_user),
) -> dict:
conn = get_db()
note = conn.execute(
"SELECT * FROM notes WHERE id = ?", (note_id,)
).fetchone()
if not note:
conn.close()
raise HTTPException(status_code=404, detail="Note not found")
conn.execute("DELETE FROM notes WHERE id = ?", (note_id,))
conn.commit()
conn.close()
return {"deleted": note_id, "message": "Note deleted successfully"}
@app.get("/notes/search")
async def search_notes(
q: str = Query(..., min_length=1),
current_user: dict = Depends(get_current_user),
) -> dict:
conn = get_db()
query = f"SELECT id, title, content, tags FROM notes WHERE user_id = {current_user['id']} AND (title LIKE '%{q}%' OR content LIKE '%{q}%')"
results = conn.execute(query).fetchall()
conn.close()
return {
"query": q,
"results": [dict(r) for r in results],
"total": len(results),
}
Tu Turno: Encuentra los 5 Errores
Antes de ver las soluciones, intenta encontrar los 5 errores. Usa el formato indicado arriba para documentar cada uno.
Pistas por categoría (si las necesitas):
Categorías de los 5 errores:
├── 1 error de naming/abstracciones
├── 2 errores de edge cases
└── 2 errores de security
Pistas Adicionales (Solo Si Estás Atascado)
Si después de 20-30 minutos no has encontrado todos, estas pistas te orientan sin dar la respuesta:
Pista para Error #1 (Security)
Busca en la sección de configuración y utilidades. ¿Cómo se almacenan las passwords? Investiga si el método usado es apropiado para hashing de passwords en 2024.
Pista para Error #2 (Security)
Busca el endpoint de búsqueda. ¿Cómo se construye la query SQL? Compara con cómo se construyen los queries en los otros endpoints.
Pista para Error #3 (Edge Case)
Mira la paginación en get_user_notes. ¿Qué operador aritmético se usa para calcular total_pages? ¿Qué pasa con el último grupo de items si no llena una página completa?
Pista para Error #4 (Naming/Abstracción)
Mira los endpoints get_note, update_note, y delete_note. Todos buscan la nota por note_id. ¿Verifican que la nota pertenece al usuario actual? ¿Qué implica esto para el nombre get_current_user — te da una falsa sensación de seguridad?
Pista para Error #5 (Edge Case/Security)
Busca en las constantes al inicio del archivo. ¿Hay algo que debería estar en variables de entorno? ¿Hay algo en la configuración que viola las prácticas de seguridad que estudiaste en la cápsula 04?
Soluciones Detalladas
Error #1: MD5 para Hashing de Passwords (Security)
Ubicación: Función hash_password
def hash_password(password: str) -> str:
return hashlib.md5(password.encode()).hexdigest()
Categoría: Security
Por qué se ve bien a primera vista:
- La función tiene un nombre descriptivo
- Usa hashlib, una librería estándar
- El password se hashea antes de almacenarse (no texto plano)
- La API es simple y limpia
El problema: MD5 es un algoritmo de hashing general, no diseñado para passwords. Sus debilidades:
- Velocidad: MD5 es extremadamente rápido — un atacante puede probar billones de combinaciones por segundo con GPUs
- Sin salt: Dos usuarios con la misma password tienen el mismo hash. Rainbow tables pre-calculadas descifran MD5 en segundos
- Colisiones conocidas: MD5 tiene colisiones demostradas — diferentes inputs pueden producir el mismo hash
- Deprecado: La industria abandonó MD5 para passwords hace más de una década
# Demostración del problema:
import hashlib
# Misma password → mismo hash (sin salt)
hashlib.md5("password123".encode()).hexdigest()
# → '482c811da5d5b4bc6d497ffa98491e38'
# Este hash está en TODAS las rainbow tables del mundo
# Un atacante lo descifra en < 1 segundo
Impacto: Si la base de datos se filtra (breach), todas las passwords se descifran en minutos.
Ver corrección
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""Hashea password con bcrypt (salt automático, cost factor configurable)."""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verifica password contra hash bcrypt."""
return pwd_context.verify(plain_password, hashed_password)
Y en el endpoint de login, cambiar:
# Antes:
if user["password_hash"] != hash_password(credentials.password):
# Después:
if not verify_password(credentials.password, user["password_hash"]):
Por qué bcrypt es correcto:
- Incluye salt automático — misma password produce hashes diferentes
- Cost factor configurable — puedes hacer el hashing más lento intencionalmente
- Diseñado específicamente para passwords — resistente a ataques con GPU
- Estándar de la industria con décadas de análisis criptográfico
Error #2: SQL Injection en Búsqueda (Security)
Ubicación: Endpoint search_notes
@app.get("/notes/search")
async def search_notes(
q: str = Query(..., min_length=1),
current_user: dict = Depends(get_current_user),
) -> dict:
conn = get_db()
query = f"SELECT id, title, content, tags FROM notes WHERE user_id = {current_user['id']} AND (title LIKE '%{q}%' OR content LIKE '%{q}%')"
results = conn.execute(query).fetchall()
conn.close()
...
Categoría: Security
Por qué se ve bien a primera vista:
- El endpoint requiere autenticación (
Depends(get_current_user)) - Filtra por
user_iddel usuario actual - El query param tiene validación
min_length=1 - Los otros endpoints del mismo archivo usan parameterized queries correctamente
El problema: Este es el único endpoint que usa f-string para construir el SQL query. Es especialmente peligroso porque está mezclado con endpoints que SÍ usan parámetros — pasa desapercibido en un review si no lees cada query individualmente.
# Ataque: extraer datos de otros usuarios
# GET /notes/search?q=' UNION SELECT id, username, password_hash, email FROM users --
# Query resultante:
# SELECT id, title, content, tags FROM notes
# WHERE user_id = 1
# AND (title LIKE '%' UNION SELECT id, username, password_hash, email FROM users --%'
# OR content LIKE '%' UNION SELECT id, username, password_hash, email FROM users --%')
# → Retorna usernames y password hashes de TODOS los usuarios
Impacto: Un usuario autenticado puede extraer datos de toda la base de datos — incluyendo passwords de otros usuarios.
Ver corrección
@app.get("/notes/search")
async def search_notes(
q: str = Query(..., min_length=1, max_length=100),
current_user: dict = Depends(get_current_user),
) -> dict:
conn = get_db()
search_term = f"%{q}%"
results = conn.execute(
"SELECT id, title, content, tags FROM notes WHERE user_id = ? AND (title LIKE ? OR content LIKE ?)",
(current_user["id"], search_term, search_term),
).fetchall()
conn.close()
return {
"query": q,
"results": [dict(r) for r in results],
"total": len(results),
}
Los tres valores (user_id, y los dos search_term) van como parámetros ?. Se agregó max_length=100 para prevenir búsquedas abusivamente largas.
Error #3: Off-by-One en Paginación (Edge Case)
Ubicación: Endpoint get_user_notes
total_pages = total // size
Categoría: Edge case
Por qué se ve bien a primera vista:
- La paginación tiene
pageysizecon defaults razonables - El offset se calcula correctamente:
(page - 1) * size - El total se obtiene de la base de datos con COUNT(*)
- El response incluye metadata de paginación
El problema:
División entera (//) trunca. Si tienes 25 notas con size=10:
total_pages = 25 // 10 # = 2 (debería ser 3)
# La página 3 tiene 5 notas, pero total_pages dice que solo existen 2 páginas
Además, no hay validación de page ni size:
# page=0 → offset = -10 → SQLite retorna resultados inesperados
# page=-5 → offset = -60 → resultados absurdos
# size=0 → ZeroDivisionError en total // 0
# size=-1 → LIMIT -1 en SQLite retorna TODOS los registros
# size=1000000 → dump de todos los datos en un request
Impacto: Los usuarios pierden acceso a las últimas notas (las de la página parcial). Valores inválidos causan crashes o datos incorrectos.
Ver corrección
import math
@app.get("/notes")
async def get_user_notes(
current_user: dict = Depends(get_current_user),
page: int = Query(default=1, ge=1),
size: int = Query(default=10, ge=1, le=100),
) -> dict:
conn = get_db()
total = conn.execute(
"SELECT COUNT(*) as count FROM notes WHERE user_id = ?",
(current_user["id"],),
).fetchone()["count"]
total_pages = math.ceil(total / size) if total > 0 else 0
if page > total_pages and total_pages > 0:
conn.close()
raise HTTPException(
status_code=404,
detail=f"Page {page} not found. Total pages: {total_pages}",
)
offset = (page - 1) * size
notes = conn.execute(
"SELECT id, title, content, tags, created_at, updated_at FROM notes WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
(current_user["id"], size, offset),
).fetchall()
conn.close()
return {
"notes": [dict(n) for n in notes],
"page": page,
"page_size": size,
"total_pages": total_pages,
"total_items": total,
"has_next": page < total_pages,
"has_previous": page > 1,
}
Cambios:
math.ceil()en vez de//para calcular total_pagesge=1en page y size — FastAPI rechaza valores ≤ 0 automáticamentele=100en size — previene dumps masivos de datos- Validación de page fuera de rango
has_nextyhas_previouspara facilitar navegación del frontend
Error #4: IDOR — Acceso a Notas de Otros Usuarios (Naming/Authorization)
Ubicación: Endpoints get_note, update_note, y delete_note
@app.get("/notes/{note_id}")
async def get_note(
note_id: int,
current_user: dict = Depends(get_current_user),
) -> dict:
conn = get_db()
note = conn.execute(
"SELECT id, user_id, title, content, tags, created_at, updated_at FROM notes WHERE id = ?",
(note_id,),
).fetchone()
conn.close()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
return dict(note)
Categoría: Naming/Abstracción (IDOR — Insecure Direct Object Reference)
Por qué se ve bien a primera vista:
- El endpoint requiere autenticación (
Depends(get_current_user)) - La presencia de
current_userda la impresión de que hay autorización - El query usa parameterized queries (no hay SQL injection)
- El código verifica que la nota existe
El problema:
El endpoint verifica que el usuario está autenticado, pero no verifica que la nota le pertenece. current_user se obtiene pero nunca se usa para filtrar. Esto es un caso clásico donde el naming engaña: tener current_user como parámetro crea la ilusión de que hay control de acceso, cuando en realidad cualquier usuario autenticado puede leer, modificar, o eliminar las notas de cualquier otro usuario.
# Ataque (como usuario alice, id=1):
# GET /notes/5 → Leer nota de bob
# PUT /notes/5 → Modificar nota de bob
# DELETE /notes/5 → Borrar nota de bob
# Solo necesitas estar autenticado — no importa de quién sea la nota
Esto aplica a los tres endpoints: get_note, update_note, y delete_note. Los tres buscan la nota solo por note_id sin filtrar por user_id.
Impacto: Cualquier usuario autenticado puede leer, modificar y eliminar notas de todos los demás usuarios. Es una violación total de privacidad y data integrity.
Ver corrección
async def get_user_note(note_id: int, user_id: int, conn: sqlite3.Connection) -> dict:
"""Obtiene una nota verificando que pertenece al usuario."""
note = conn.execute(
"SELECT id, user_id, title, content, tags, created_at, updated_at FROM notes WHERE id = ? AND user_id = ?",
(note_id, user_id),
).fetchone()
if note is None:
raise HTTPException(status_code=404, detail="Note not found")
return dict(note)
@app.get("/notes/{note_id}")
async def get_note(
note_id: int,
current_user: dict = Depends(get_current_user),
) -> dict:
conn = get_db()
try:
return get_user_note(note_id, current_user["id"], conn)
finally:
conn.close()
@app.put("/notes/{note_id}")
async def update_note(
note_id: int,
update: NoteUpdate,
current_user: dict = Depends(get_current_user),
) -> dict:
conn = get_db()
try:
note = get_user_note(note_id, current_user["id"], conn)
updates = {}
if update.title is not None:
updates["title"] = update.title
if update.content is not None:
updates["content"] = update.content
if update.tags is not None:
updates["tags"] = ",".join(update.tags)
if updates:
updates["updated_at"] = datetime.now().isoformat()
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [note_id, current_user["id"]]
conn.execute(
f"UPDATE notes SET {set_clause} WHERE id = ? AND user_id = ?",
values,
)
conn.commit()
return get_user_note(note_id, current_user["id"], conn)
finally:
conn.close()
@app.delete("/notes/{note_id}")
async def delete_note(
note_id: int,
current_user: dict = Depends(get_current_user),
) -> dict:
conn = get_db()
try:
get_user_note(note_id, current_user["id"], conn)
conn.execute(
"DELETE FROM notes WHERE id = ? AND user_id = ?",
(note_id, current_user["id"]),
)
conn.commit()
return {"deleted": note_id}
finally:
conn.close()
Cambios clave:
- Helper
get_user_notefiltra pornote_idYuser_id - Cada endpoint usa el helper — imposible acceder a notas de otros
- El DELETE y UPDATE también filtran por
user_iden la query try/finallygarantiza que la conexión se cierra
Error #5: JWT Secret Hardcoded (Security/Edge Case)
Ubicación: Constantes de configuración
JWT_SECRET = "notes-api-secret-key-2024-production"
JWT_ALGORITHM = "HS256"
DB_PATH = "notes.db"
Categoría: Security
Por qué se ve bien a primera vista:
- Está al inicio del archivo como constante, siguiendo convención
- El nombre
JWT_SECRETes descriptivo - El valor parece un secret legítimo (no es "secret" o "1234")
- Está separado de la lógica de negocio
El problema: El JWT secret está hardcoded en el código fuente. Si este archivo llega a un repositorio git (público o privado):
- Cualquiera con acceso al repo puede crear JWT tokens válidos — bypass total de autenticación
- El secret es predecible — contiene el año y el nombre de la app, un atacante podría adivinarlo
- No se puede rotar sin cambiar el código — si sospechas que el secret fue comprometido, necesitas deploy
- Es el mismo en todos los ambientes — dev, staging, y producción comparten el mismo secret
# Un atacante con el secret puede crear tokens para cualquier usuario:
from jose import jwt
fake_token = jwt.encode(
{"sub": "1", "username": "admin", "exp": datetime.utcnow() + timedelta(hours=24)},
"notes-api-secret-key-2024-production",
algorithm="HS256",
)
# Este token es válido — el atacante es ahora el usuario 1
Impacto: Bypass completo de autenticación. Un atacante puede impersonar cualquier usuario.
Ver corrección
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
jwt_secret: str
jwt_algorithm: str = "HS256"
database_url: str = "notes.db"
model_config = {"env_file": ".env"}
@lru_cache
def get_settings() -> Settings:
return Settings()
Y actualizar las funciones que usan el secret:
def create_token(user_id: int, username: str) -> str:
settings = get_settings()
payload = {
"sub": str(user_id),
"username": username,
"exp": datetime.now(timezone.utc) + timedelta(hours=24),
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
settings = get_settings()
try:
payload = jwt.decode(
credentials.credentials,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
...
El archivo .env (nunca en git):
JWT_SECRET=un-secret-generado-con-openssl-rand-hex-32-aqui
Y en .gitignore:
.env
.env.*
!.env.example
jwt_secret: str sin default — la aplicación no inicia si no está configurado. Fail-fast es mejor que funcionar con un secret inseguro.
Tabla Resumen de los 5 Errores
| # | Error | Categoría | Severidad | Línea/Función |
|---|---|---|---|---|
| 1 | MD5 para password hashing | Security | Critical | hash_password() |
| 2 | SQL injection en búsqueda | Security | Critical | search_notes() |
| 3 | Off-by-one en paginación | Edge Case | Medium | get_user_notes() |
| 4 | IDOR — acceso a notas ajenas | Naming/Auth | Critical | get_note(), update_note(), delete_note() |
| 5 | JWT secret hardcoded | Security | Critical | Constantes de configuración |
Distribución por categoría
Security: 3 errores (#1, #2, #5)
Edge Case: 1 error (#3)
Naming/Abstracción: 1 error (#4)
El error #4 es interesante porque cruza categorías: es un problema de autorización (security) que se manifiesta como un error de naming/abstracción — la presencia de current_user crea la ilusión de que hay control de acceso cuando no lo hay.
Reflexión Post-Ejercicio
¿Qué hizo difícil encontrar cada error?
Error #1 (MD5): Se ve como hashing legítimo. MD5 genera un hash,
el password no se almacena en texto plano. El problema es sutil
— es el algoritmo incorrecto, no la ausencia de hashing.
Error #2 (SQL injection): Está en UN endpoint de ~10. Los otros
usan parameterized queries. Es fácil asumir que si 9 están bien,
el 10 también lo está.
Error #3 (Paginación): // vs math.ceil() es la diferencia de un
solo carácter. El cálculo se ve correcto a simple vista. Solo se
manifiesta cuando total_items no es múltiplo de size.
Error #4 (IDOR): La PRESENCIA de current_user engaña. El cerebro
ve "hay autenticación" y asume "hay autorización." La dependency
se inyecta pero su valor nunca se usa para filtrar.
Error #5 (JWT hardcoded): Parece una constante normal. Las constantes
al inicio del archivo son un patrón aceptado en Python. El problema
es que este valor particular no debería ser una constante en código.
Patrones que debes llevar al proyecto integrador
1. Verificar CADA query SQL — ¿usa parámetros o f-strings?
2. Verificar que autenticación ≠ autorización — ¿el user_id se usa para filtrar?
3. Verificar algoritmos de seguridad — ¿MD5, SHA1, o bcrypt?
4. Verificar paginación — ¿// o math.ceil()? ¿Validación de page/size?
5. Verificar secrets — ¿hardcoded o en environment variables?
Conexión con Proyecto
Este ejercicio es tu ensayo general para el proyecto integrador del módulo 8. Las diferencias:
Este ejercicio (módulo 5): Proyecto integrador (módulo 8):
├── 1 archivo, ~150 líneas ├── 8-12 archivos, ~500-800 líneas
├── 5 errores ├── 15-20 errores
├── 3 categorías ├── 5 categorías (+ hallucinations, lógica)
├── Errores embebidos ├── Errores embebidos
└── Solo encontrar y corregir └── Encontrar + corregir + documentar + retrospectiva
Si encontraste 4-5 errores en este ejercicio, estás preparado para el proyecto.
Troubleshooting
"Encontré errores adicionales que no están en la lista de 5"
Bien hecho. El código tiene más issues menores que los 5 principales — por ejemplo, la conexión a la base de datos no usa context managers, datetime.utcnow() está deprecado, o el endpoint de registro no valida el formato del email. Estos son issues legítimos pero de menor severidad que los 5 principales.
"No encontré el error #4 (IDOR). ¿Es realmente un error?"
Sí, y es uno de los más comunes en aplicaciones reales. La confusión entre autenticación ("¿quién eres?") y autorización ("¿tienes permiso para hacer esto?") es una de las vulnerabilidades más frecuentes según OWASP (Broken Access Control es el #1).
"¿Debo corregir los 5 errores o solo identificarlos?"
Ambos. Identificar sin corregir demuestra que reconoces el patrón. Corregir demuestra que sabes la solución. En el proyecto integrador del módulo 8, necesitarás hacer ambos.
"¿Cómo práctico más?"
Genera una aplicación FastAPI con Claude Code y aplica el checklist de 5 puntos de la sección "Patrones que debes llevar." Busca los mismos patrones. Con práctica, los detectarás automáticamente.
"¿Estos errores son reales o inventados?"
Son reales. Cada uno de estos errores aparece frecuentemente en código generado por LLMs. MD5 para passwords, SQL injection en un endpoint de búsqueda, IDOR en CRUD — son patrones documentados en la literatura de seguridad de AI code.
Ejercicios Adicionales
Ejercicio Extra 1: Corregir toda la aplicación
Toma el código completo de NotesAPI y aplica las 5 correcciones. Verifica que la aplicación sigue funcionando después de cada corrección.
Ver criterio de validación
Tu versión corregida debe cumplir:
- ✅ Passwords hasheadas con bcrypt (instalar passlib[bcrypt])
- ✅ Todas las queries SQL usan parameterized queries
- ✅ Paginación con
math.ceil()y validaciónge=1, le=100 - ✅ Todos los endpoints de notas filtran por
user_id - ✅ JWT secret cargado desde variable de entorno
- ✅ La aplicación inicia y responde correctamente
Ejercicio Extra 2: Agregar tests que verifiquen las correcciones
Escribe un test para cada corrección que verifica que el error ya no existe.
Ver ejemplo de tests
import pytest
from fastapi.testclient import TestClient
def test_user_cannot_access_other_users_notes(client: TestClient):
"""Verifica que IDOR está corregido."""
# Crear usuario 1 y una nota
r1 = client.post("/auth/register", json={"username": "alice", "password": "pass123", "email": "a@x.com"})
token1 = r1.json()["token"]
note = client.post(
"/notes",
json={"title": "Private Note", "content": "Secret"},
headers={"Authorization": f"Bearer {token1}"},
)
note_id = note.json()["id"]
# Crear usuario 2
r2 = client.post("/auth/register", json={"username": "bob", "password": "pass456", "email": "b@x.com"})
token2 = r2.json()["token"]
# Usuario 2 intenta acceder a nota de usuario 1
response = client.get(
f"/notes/{note_id}",
headers={"Authorization": f"Bearer {token2}"},
)
assert response.status_code == 404 # No debería encontrar la nota
def test_search_resists_sql_injection(client: TestClient):
"""Verifica que SQL injection está corregido."""
r = client.post("/auth/register", json={"username": "test", "password": "pass123", "email": "t@x.com"})
token = r.json()["token"]
response = client.get(
"/notes/search",
params={"q": "' UNION SELECT id, username, password_hash, email FROM users --"},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
# No debe retornar datos de la tabla users
for result in response.json()["results"]:
assert "password_hash" not in result
def test_pagination_total_pages_correct(client: TestClient):
"""Verifica que total_pages usa ceil, no floor division."""
r = client.post("/auth/register", json={"username": "pager", "password": "pass123", "email": "p@x.com"})
token = r.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# Crear 15 notas
for i in range(15):
client.post("/notes", json={"title": f"Note {i}", "content": "test"}, headers=headers)
# Con size=10, 15 notas deben dar 2 páginas (no 1)
response = client.get("/notes?size=10", headers=headers)
assert response.json()["total_pages"] == 2
# La página 2 debe tener 5 notas
response = client.get("/notes?page=2&size=10", headers=headers)
assert len(response.json()["notes"]) == 5
Ejercicio Extra 3: Buscar errores en tu propio código
Genera una aplicación FastAPI con Claude Code (el prompt que quieras) y aplica el checklist de 5 puntos. Documenta los errores que encuentres.
Ver checklist para tu código
Checklist de 5 puntos para código AI-generated:
1. [ ] ¿Todas las queries SQL usan parameterized queries?
Buscar: f"SELECT, f"INSERT, f"UPDATE, f"DELETE
Fix: Reemplazar con ? o %s
2. [ ] ¿Los endpoints verifican ownership (no solo autenticación)?
Buscar: endpoints con Depends(get_current_user) que no filtran por user_id
Fix: Agregar AND user_id = ? a las queries
3. [ ] ¿Los secrets están en environment variables?
Buscar: strings que parecen keys, passwords, o tokens en el código
Fix: Mover a pydantic-settings con .env
4. [ ] ¿La paginación usa math.ceil() con validación?
Buscar: // para calcular total_pages, page sin ge=1
Fix: math.ceil() + Query(ge=1, le=100)
5. [ ] ¿Los passwords usan bcrypt (no MD5/SHA)?
Buscar: hashlib.md5, hashlib.sha1, hashlib.sha256 para passwords
Fix: passlib con bcrypt
Resumen
- El ejercicio presenta una aplicación FastAPI realista con 5 errores embebidos de las 3 categorías del módulo
- Los errores son sutiles: MD5 en vez de bcrypt, SQL injection en un solo endpoint de 10, off-by-one en paginación, IDOR oculto por la presencia de
current_user, y JWT secret hardcoded - La dificultad está en que el código funciona — los errores no causan crashes inmediatos
- El error #4 (IDOR) es el más instructivo: demuestra que autenticación ≠ autorización
- El checklist de 5 puntos es tu herramienta portable para revisar cualquier código AI-generated
- Este ejercicio es tu ensayo para el proyecto integrador del módulo 8
Recursos Adicionales
- OWASP Top 10 — 2021 - Referencia estándar de vulnerabilidades web (IDOR, injection, broken access control)
- CWE-639: Authorization Bypass Through User-Controlled Key - La clasificación formal de IDOR
- passlib Documentation - Librería Python para hashing seguro de passwords
- SQLite Parameterized Queries - Documentación oficial de Python sobre queries parametrizados
- FastAPI Security Best Practices - Guía oficial de seguridad en FastAPI
Siguiente módulo: Debugging con Claude Code — cómo diagnosticar y resolver errores usando Claude Code como herramienta de debugging.
Debugging & Code Review with Claude Code — Módulo 5, Cápsula 05 Claude Code Agentic Development Path — Guía #6 de 11