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:

  1. Identificar cada patrón de error
  2. Explicar por qué es un problema (no basta con señalar — justifica)
  3. 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:

  1. Velocidad: MD5 es extremadamente rápido — un atacante puede probar billones de combinaciones por segundo con GPUs
  2. Sin salt: Dos usuarios con la misma password tienen el mismo hash. Rainbow tables pre-calculadas descifran MD5 en segundos
  3. Colisiones conocidas: MD5 tiene colisiones demostradas — diferentes inputs pueden producir el mismo hash
  4. 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_id del 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 page y size con 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_pages
  • ge=1 en page y size — FastAPI rechaza valores ≤ 0 automáticamente
  • le=100 en size — previene dumps masivos de datos
  • Validación de page fuera de rango
  • has_next y has_previous para 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_user da 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_note filtra por note_id Y user_id
  • Cada endpoint usa el helper — imposible acceder a notas de otros
  • El DELETE y UPDATE también filtran por user_id en la query
  • try/finally garantiza 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_SECRET es 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):

  1. Cualquiera con acceso al repo puede crear JWT tokens válidos — bypass total de autenticación
  2. El secret es predecible — contiene el año y el nombre de la app, un atacante podría adivinarlo
  3. No se puede rotar sin cambiar el código — si sospechas que el secret fue comprometido, necesitas deploy
  4. 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

#ErrorCategoríaSeveridadLínea/Función
1MD5 para password hashingSecurityCriticalhash_password()
2SQL injection en búsquedaSecurityCriticalsearch_notes()
3Off-by-one en paginaciónEdge CaseMediumget_user_notes()
4IDOR — acceso a notas ajenasNaming/AuthCriticalget_note(), update_note(), delete_note()
5JWT secret hardcodedSecurityCriticalConstantes 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ón ge=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

  1. OWASP Top 10 — 2021 - Referencia estándar de vulnerabilidades web (IDOR, injection, broken access control)
  2. CWE-639: Authorization Bypass Through User-Controlled Key - La clasificación formal de IDOR
  3. passlib Documentation - Librería Python para hashing seguro de passwords
  4. SQLite Parameterized Queries - Documentación oficial de Python sobre queries parametrizados
  5. 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