# Hardening: abuso de pre-signed uploads (Opción B) ## Contexto Con la Opción B el backend genera URLs pre-firmadas (pre-signed) para que el cliente suba ficheros directamente a S3, y luego el backend crea/actualiza entidades en DB referenciando `pictureName/audioName`. Esto mejora latencia y descarga el host/servicio, pero abre un vector de abuso: un usuario puede llamar muchas veces a `RequestPlayListPictureUpload` / `RequestTrackUploadUrls` y subir muchos objetos a S3 sin llegar a crear la entidad final (objetos “huérfanos”). ## Qué pasa hoy si el usuario spamea - Se generan muchas URLs pre-firmadas (costo bajo pero no cero). - El usuario puede subir muchos objetos a S3 sin relación con una entidad en DB. - El impacto principal es: - coste/uso de S3 (storage + requests) - posible saturación del componente de generación de URLs ## Objetivo Reducir abuso y coste, manteniendo: - UX simple - backend sin streams - workflows asíncronos + MassTransit Outbox ## Qué significa “sólido” aquí Como el upload es directo a S3 (o compatible), el backend no puede garantizar “single-use” del upload en el lado del storage mientras una URL sea válida. Por tanto, el objetivo realista es: - Asegurar que **cualquier abuso solo pueda impactar un prefijo temporal** barato de limpiar. - Poner límites duros (cuotas) en DB para que el coste tenga un techo. - No aceptar referencias a objetos no válidos (verificación antes de consumir). - Evitar pérdida de datos en reemplazos (no borrar la versión previa hasta éxito). ## Principio de configuración (no hardcode) Todas las rutas/prefijos/TTLs/límites deben ser **configurables**. Regla: - En `Application`/`Infrastructure` no debe haber strings hardcodeadas de keys S3 (p.ej. `temp/`, `uploads/`, `PlayLists/`, `Tracks/`). - Las rutas deben centralizarse en opciones/config (p.ej. `StorageRoutes`, `PlayListRoutes`, `TrackRoutes`, o similar), de forma que cambiar la convención de keys no requiera tocar lógica. Los ejemplos de este documento son **convenciones sugeridas** y deben leerse como plantillas parametrizadas. ## Invariantes de seguridad (reglas que no se rompen) - El cliente solo puede escribir en rutas temporales bajo `{TempRootPrefix}/...` (o el prefijo temporal equivalente). La ruta final **nunca** recibe credenciales pre-firmadas. - Cada intent emite un `Key` **nuevo** (GUID). No hay keys “estables” para reusar. - Una entidad solo apunta a un `pictureName/audioName` si su intent asociado se valida y se consume. - Reemplazos: el borrado de la versión anterior ocurre **al final** del workflow, tras actualizar DB. ## Prioridad (coste alto) Si el coste es el principal riesgo, el orden recomendado es: 1) **Cuotas duras en DB** (intents activos + bytes activos) + job de expiración. 2) Prefijo temporal + cleanup por prefijo (lifecycle si existe, y job como fallback). 3) Validación de objeto (HEAD) antes de consumir. 4) (Opcional) Migrar a pre-signed POST si el backend de storage lo soporta bien. ## Decisiones validadas (no tocaría) - El orden de prioridad (cuotas → prefijo temporal → HEAD → POST) es el más sensato cuando el **coste** es el riesgo principal. - La advertencia sobre S3-compatible / SeaweedFS es importante: el diseño debe ser seguro sin depender de features avanzadas. - El principio de no hardcodear rutas y centralizar prefijos/convenciones en config es coherente con el estilo del repo. - La semántica de errores debe permanecer separada del mapeo a códigos/respuestas (responsabilidad del host/presentación). - En reemplazos, no borrar la versión anterior hasta éxito del workflow es clave para resiliencia con Courier. ## Mitigaciones recomendadas (pueden coexistir) ### 1) “Upload intents” en DB (control y conciliación) Persistir una intención de upload cuando se genera un pre-signed URL. > Importante: **no usar el nombre original del fichero** como `pictureName/audioName`. La key debe usar un **identificador generado** (p.ej. GUID) para evitar colisiones, inputs “raros” y facilitar cleanup. Sugerencia práctica: - `objectName = {guid}.{ext}` (o directamente `{guid}` si la extensión no aporta valor) - Guardar `originalFileName` solo como metadata opcional (si se necesita para UI), pero **nunca** como key. Modelo sugerido (mínimo): - `UploadIntent` - `Id` - `UserId` - `Bucket` - `Key` - `ContentType` - `ExpectedSizeBytes` (opcional; si el caller conoce el tamaño) - `ExpiresAt` - `Status`: `Issued | Consumed | Expired` - `CreatedAt` Opcional (útil para auditoría/UX): - `OriginalFileName` - `Purpose` (`TrackPicture | TrackAudio | PlayListPicture`) Reglas: - Al pedir URL (Request*Upload*): - insertar intent `Issued` - imponer límites por usuario (ver “Cuotas”) - si supera el límite: fallar con un error semántico (p.ej. `UploadIntentLimitExceeded`) - Al crear/actualizar entidad (Create*/Update* V2): - validar que el `pictureName/audioName` recibido corresponde a un intent `Issued` del mismo `UserId` y no expirado - validar que el objeto existe en S3 (HEAD) y que tamaño / `Content-Type` cumplen la policy del backend - marcar intent como `Consumed` #### Cuotas (anti-cost) 🔴 Alta Además del conteo de intents, imponer **cuotas por bytes** para que el coste tenga techo. Recomendación mínima: - `MaxActiveUploadIntentsPerUser` (número) - `MaxActiveUploadBytesPerUser` (bytes) — suma de objetos temporales “en vuelo” por usuario Cómo aproximarlo sin streams: - Guardar `ExpectedSizeBytes` en el intent (si está disponible). - Si no se dispone del tamaño, usar un valor conservador por `Purpose` (p.ej. imagen <= X, audio <= Y) y validarlo en HEAD antes de consumir. > Nota: si el caller puede mentir sobre tamaño, no confíes ciegamente en `ExpectedSizeBytes`; úsalo como guardrail y valida al consumir. #### Semántica de errores (intent inválido) 🟡 Media Definir errores semánticos y dejar el mapeo al “caller” (host/presentación): - `UploadIntentLimitExceeded` - `UploadIntentNotFoundOrNotAccessible` (si se quiere no filtrar existencia) - `UploadIntentAlreadyConsumed` - `UploadIntentExpired` - `UploadObjectNotFound` - `UploadObjectPolicyViolation` (tamaño, `Content-Type`, extensión, etc.) #### Nota sobre race conditions en el límite 🟡 Media El check “`count(Issued) < Max`” es vulnerable a concurrencia (dos requests paralelas pueden pasar el check y crear 2 intents extra). Mitigaciones (de menor a mayor robustez): - Ejecutar **check + insert** en la **misma transacción** con aislamiento fuerte (p.ej. `SERIALIZABLE`). - Usar un mecanismo de lock por usuario (p.ej. advisory lock en PostgreSQL) durante la emisión. - Modelar el límite con una tabla/contador por usuario y aplicar **update condicional** (estilo “compare-and-swap”). #### Job de expiración de intents 🔴 Alta Aunque `ExpiresAt` esté en DB, hace falta un mecanismo que marque y limpie intents caducados: - Un job periódico (worker/cron) que: - marque intents `Issued` con `ExpiresAt < now` como `Expired` - opcionalmente encole el borrado del objeto S3 (si existe) Si se aplica lifecycle en S3 (ver sección 2), el job puede ser “solo DB” (marcar `Expired`) y dejar el borrado real a S3. Adicionalmente, este job es un buen sitio para: - borrar intents `Expired` antiguos (retención de auditoría configurable) - emitir métricas/contadores (intents expirados por usuario) Ventajas: - Previene spam con reglas duras y auditables. - Permite cleanup de huérfanos con certeza. Coste: - entidad + migración + lógica de validación. ### 2) Limitar el scope del pre-signed URL (reducción de daño) Aplicar restricciones conservadoras: - Reducir TTL: - `ExpiresInSeconds` más bajo (p.ej. 60–120s en vez de 600s) - Validar/limitar: - `ContentType` permitido - extensiones permitidas (`FileType`) - Usar keys temporales por usuario: - Opción recomendada (facilita lifecycle por prefijo): - `{TempRootPrefix}/{userId}/playlists/{guid}.{ext}` - `{TempRootPrefix}/{userId}/tracks/{guid}.{ext}` - Alternativa (si se quiere mantener todo bajo `uploads/{userId}`): - `{UploadsRootPrefix}/{userId}/PlayLists/TempUploads/{guid}.{ext}` - `{UploadsRootPrefix}/{userId}/Tracks/TempUploads/{guid}.{ext}` Luego, en el workflow/worker: - mover/copiar desde la ruta temporal (p.ej. `temp/...` o `.../TempUploads/...`) a la ruta final (`OriginalPictures/...` / `OriginalAudios/...`) - borrar el objeto temporal Ventajas: - Cleanup masivo sencillo (por prefijo `{TempRootPrefix}/`) - Reduce el impacto de huérfanos. #### S3 Lifecycle Rule en `TempUploads` 🔴 Alta Configurar una **Lifecycle Rule** en el bucket para auto-expirar objetos bajo el prefijo de temporales, por ejemplo: - Prefijo: `{TempRootPrefix}/` - Expiración: 1–7 días (según tolerancia a reintentos) Nota: las reglas de lifecycle por **prefijo** no soportan comodines en medio (p.ej. `uploads/*/...`). Si se usa la alternativa `uploads/{userId}/.../TempUploads/...`, para filtrar correctamente se recomienda: - cambiar la convención de key para que el prefijo sea común (p.ej. `{TempRootPrefix}/...`), o - usar **tags** en los objetos (p.ej. `temporary=true`) y filtrar la lifecycle rule por tag (si se puede firmar el tagging en el upload). Esto evita que el coste de huérfanos crezca indefinidamente incluso si el job de cleanup falla. #### Cleanup backend (fallback) 🔴 Alta No dependas solo de lifecycle del storage (en S3 compatibles puede variar). Añadir un job de cleanup que: - liste por prefijo temporal `{TempRootPrefix}/` - borre objetos con edad > `TempUploadsRetentionDays` Este job debe ser: - incremental (paginación) - tolerante a errores (reintentos) - con límites de throughput (para no auto-generar costes por borrar en masa) #### Restricción de `Content-Type` en S3 🟡 Media Si el objetivo es enforcement “duro” a nivel de S3 (no solo validación posterior), **pre-signed POST** suele ser más robusto que PUT. **pre-signed PUT** (lo actual en este repo): - Permite fijar `Content-Type` si se pre-firma incluyendo ese header. - Es más limitado para imponer condiciones ricas (tamaño, tags, etc.). - Recomendación: combinar con validación posterior (HEAD) antes de consumir el intent. **pre-signed POST** (recomendado si buscas más control): - Permite policy conditions como: - `content-length-range` (tamaño máximo) - `eq`/`starts-with` para `Content-Type` - key/prefijo (el cliente no elige keys fuera del scope) - headers requeridos (p.ej. SSE) - opcional: tagging (p.ej. `temporary=true`) para lifecycle por tag Trade-off: el cliente sube con `multipart/form-data` (POST), pero el backend sigue sin manejar streams. #### Nota S3-compatible / SeaweedFS 🟡 Media No todos los backends S3 compatibles implementan al 100%: - policy conditions de POST - tagging en upload - respuestas/errores idénticos a AWS Recomendación: - Diseñar el sistema para ser seguro **sin depender** de condiciones avanzadas (keys únicas + prefijo temporal + cuotas + verificación HEAD). - Tratar POST/condiciones como una mejora opcional, tras validar compatibilidad del backend. #### Reemplazos: no borrar originales antiguos hasta éxito ✅ Al actualizar una imagen/audio existente: - Subir el nuevo binario a la ruta temporal (p.ej. `{TempRootPrefix}/.../{newGuid}`). - Procesar (resize/transcode) y subir a rutas finales con nombres nuevos. - Actualizar la entidad en DB para apuntar a los nuevos `*PictureName/*AudioFolderName`. - **Solo al final** borrar los objetos antiguos (original + sizes / audio procesado), cuando el workflow haya completado. Esto evita perder la versión anterior si el worker falla a mitad (punto crítico para resiliencia). ## Validación del objeto antes de consumir (HEAD) 🔴 Alta Antes de consumir un intent y actualizar DB: - `HEAD Bucket/Key` - validar `Content-Type` esperado (según `Purpose`) - validar tamaño máximo (`MaxUploadBytes` o por propósito) - (opcional) validar extensión inferida de `Key` si se usa Si falla la validación: - no consumir el intent - marcarlo como violación o dejarlo expirar - el cleanup se encarga del objeto ## Orden de implementación sugerido 1) Upload intents en DB (límite de activos + validación al crear/actualizar) 2) Reducir TTL y mover a rutas temporales + S3 lifecycle + cleanup 3) Si se requiere enforcement fuerte: migrar a pre-signed POST + policy conditions ## Parámetros recomendados (config) - `UploadUrlExpiresInSeconds` (default: 120) - `MaxActiveUploadIntentsPerUser` (default: 5) - `MaxActiveUploadBytesPerUser` (default sugerido: depende de coste; p.ej. 200MB) - `AllowedPictureExtensions` / `AllowedAudioExtensions` - `AllowedContentTypes` (lista blanca) - `TempUploadsRetentionDays` (default: 3) - `PresignedUploadMethod` (default sugerido: `POST` si se quiere enforcement fuerte; `PUT` si se prioriza simplicidad) - `MaxUploadBytes` (límite máximo de tamaño por upload) Jobs (config): - `UploadIntentsExpirationJobIntervalSeconds` - `TempUploadsCleanupJobIntervalSeconds` - `ExpiredIntentsRetentionDays` Rutas/prefijos (ejemplos): - `TempRootPrefix` (default sugerido: `temp`) - `UploadsRootPrefix` (default sugerido: `uploads`) ## Notas - Estas defensas complementan el Outbox: el Outbox garantiza entrega de eventos tras commit, pero no evita el abuso de generación de URLs o almacenamiento de objetos huérfanos.