13 KiB
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/Infrastructureno 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
Keynuevo (GUID). No hay keys “estables” para reusar. - Una entidad solo apunta a un
pictureName/audioNamesi 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:
- Cuotas duras en DB (intents activos + bytes activos) + job de expiración.
- Prefijo temporal + cleanup por prefijo (lifecycle si existe, y job como fallback).
- Validación de objeto (HEAD) antes de consumir.
- (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
originalFileNamesolo como metadata opcional (si se necesita para UI), pero nunca como key.
Modelo sugerido (mínimo):
UploadIntentIdUserIdBucketKeyContentTypeExpectedSizeBytes(opcional; si el caller conoce el tamaño)ExpiresAtStatus:Issued | Consumed | ExpiredCreatedAt
Opcional (útil para auditoría/UX):
OriginalFileNamePurpose(TrackPicture | TrackAudio | PlayListPicture)
Reglas:
- Al pedir URL (RequestUpload):
- insertar intent
Issued - imponer límites por usuario (ver “Cuotas”)
- si supera el límite: fallar con un error semántico (p.ej.
UploadIntentLimitExceeded)
- insertar intent
- Al crear/actualizar entidad (Create*/Update* V2):
- validar que el
pictureName/audioNamerecibido corresponde a un intentIssueddel mismoUserIdy no expirado - validar que el objeto existe en S3 (HEAD) y que tamaño /
Content-Typecumplen la policy del backend - marcar intent como
Consumed
- validar que el
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
ExpectedSizeBytesen 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):
UploadIntentLimitExceededUploadIntentNotFoundOrNotAccessible(si se quiere no filtrar existencia)UploadIntentAlreadyConsumedUploadIntentExpiredUploadObjectNotFoundUploadObjectPolicyViolation(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
IssuedconExpiresAt < nowcomoExpired - opcionalmente encole el borrado del objeto S3 (si existe)
- marque intents
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
Expiredantiguos (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:
ExpiresInSecondsmás bajo (p.ej. 60–120s en vez de 600s)
- Validar/limitar:
ContentTypepermitido- 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}
- Opción recomendada (facilita lifecycle por prefijo):
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-Typesi 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-withparaContent-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-Typeesperado (segúnPurpose) - validar tamaño máximo (
MaxUploadByteso por propósito) - (opcional) validar extensión inferida de
Keysi 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
- Upload intents en DB (límite de activos + validación al crear/actualizar)
- Reducir TTL y mover a rutas temporales + S3 lifecycle + cleanup
- 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/AllowedAudioExtensionsAllowedContentTypes(lista blanca)TempUploadsRetentionDays(default: 3)PresignedUploadMethod(default sugerido:POSTsi se quiere enforcement fuerte;PUTsi se prioriza simplicidad)MaxUploadBytes(límite máximo de tamaño por upload)
Jobs (config):
UploadIntentsExpirationJobIntervalSecondsTempUploadsCleanupJobIntervalSecondsExpiredIntentsRetentionDays
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.