Lo que los tutoriales no te dicen: implementando un sistema de upload en producción con Cloudflare R2 en NestJS

Tiempo de lectura: 6 minutos

Introducción

La mayoría de tutoriales sobre sistemas de upload te muestran cómo recibir un archivo, validarlo y guardarlo en disco o en un bucket S3. Funciona, pero escalar eso a producción es otra historia.

Cuando implementas un sistema real, te encuentras con problemas que los ejemplos simples no cubren:

  • ¿Qué pasa si el usuario envía un archivo .exe con extensión .jpg?
  • ¿Cómo evitas que alguien suba un GIF de 500MB que colapsa tu servidor al procesarlo?
  • ¿Cómo evitas race conditions cuando múltiples solicitudes confirman el mismo upload?
  • ¿Cómo controlas el ancho de banda para que un usuario malintencionado no drene tu cuota?

En este post, te muestro cómo implementar un sistema completo de upload a Cloudflare R2 en NestJS con múltiples capas de seguridad. No es código para copiar y pegar — es un enfoque con decisiones que funcionan en escenarios reales.

Por qué R2? Compatible con S3 API, sin egress fees, CDN integrado con Cloudflare, y pricing predecible. Perfecto para proyectos que escalarán.

Arquitectura del flujo

El flujo no sigue el modelo tradicional de «recibir archivo en el servidor → validar → guardar». En su lugar, usamos presigned URLs que delegan el upload directamente a R2. El backend nunca toca los bytes raw durante el upload:

  1. El cliente solicita una URL pre-firmada — el servidor valida la solicitud (auth, quota) y genera una URL de upload directo a R2
  2. El cliente sube el archivo directamente a R2 usando esa URL — tu servidor ni ve el archivo
  3. El cliente notifica al servidor que el upload está completo
  4. El servidor confirma, valida magic bytes, procesa la imagen (strip EXIF, thumbnail) y re-sube a R2
  5. Un cron de limpieza elimina uploads pendientes expirados cada 10 minutos

¿Por qué este flujo? Tu servidor nunca procesa el upload real. Solo valida, coordina y procesa tras la confirmación. Si tienes 100 uploads simultáneos de 10MB, tu servidor procesa cero de esos megabytes durante la subida.

Endpoints

El sistema expone tres endpoints REST:

  • POST /api/uploads/presign — solicita una URL de upload pre-firmada
  • POST /api/uploads/confirm — confirma y procesa el upload
  • DELETE /api/uploads/:id — elimina un upload existente

Todos requieren autenticación (JWT, API key, o el mecanismo que uses). El actor ID se extrae siempre del token autenticado — nunca del request body. Esto previene cualquier operación cross-actor.

Las capas de seguridad

1. Autenticación

Todos los endpoints requieren autenticación. El actor ID se extrae siempre del token. No existe ningún parámetro en el request para especificar un actor destino. Un actor nunca puede afectar los uploads de otro.

2. Scoping de presigned URL

La URL firmada está bloqueada a parámetros exactos:

  • Content-Type y Content-Length declarados en presign — el cliente no puede cambiarlos sin invalidar la firma
  • R2 key específico: actors/{actorId}/{purpose}/{uuid}.{ext} — un actor solo puede escribir en su propio prefix
  • TTL de 5 minutos — tiempo suficiente para un upload normal, no para compartir o abusar
  • Metadata { 'actor-id': actorId } embebida en el objeto para trazabilidad

3. Bandwidth y quota guards

Verificados en presign() antes de generar la URL. Si alguno falla, se devuelve 403:

GuardLímitePreviene
Uploads pendientes por actor3Presign cycling
Quota diaria confirmada50 MB/díaBandwidth abuse
Uploads confirmados totales50Bucket bloat
Límite horario por propósitoavatar: 5/h, post_media: 20/hRapid-fire uploads

Estos límites son configurables por propósito. Un avatar necesita controles más estrictos que una imagen de post.

4. Validación con magic bytes

En confirm(), se leen los primeros 12 bytes via GetObject Range y se comparan contra firmas conocidas:

TipoFirma
JPEGFF D8 FF
PNG89 50 4E 47
WebPRIFF + bytes 8-12 = WEBP
GIFGIF87a o GIF89a

¿Por qué post-upload? Porque no puedes validar magic bytes antes de que el archivo esté en el bucket. Una vez subido, descargar 12 bytes es trivial. Si el contenido real no coincide con el content_type declarado, se rechaza y se borra el archivo de R2.

5. Protección contra decompression bombs

Una imagen comprimida puede ser pequeña en disco pero expandirse a resoluciones masivas al descomprimirla. Un PNG de 100KB podría representar 100,000 × 100,000 píxeles que ocuparían 40GB en memoria.

Sharp tiene limitInputPixels que previene esto verificando el límite antes de descomprimir:

sharp(buffer, { limitInputPixels: 25_000_000 }) // ~25 megapixels max

Plus un hard check: si width > 4096 o height > 4096, se rechaza directamente. Nunca explota en memoria porque Sharp verifica el límite antes de intentar descomprimir.

6. Protección contra GIF frame bombs

Un GIF frame bomb contiene cientos de frames de 1×1 píxel. El archivo es pequeño, pero al procesarlo el servidor intenta renderizar miles de frames consumiendo CPU masivamente.

La solución: limitar el número de frames. Sharp extrae metadatos del GIF antes de procesarlo:

const metadata = await sharp(buffer).metadata();
if (metadata.pages && metadata.pages > 50) {
  throw new Error('GIF has too many frames');
}

Solo se decodifica el primer frame para inspección de metadatos. Si supera los 50 frames, se rechaza. La mayoría de GIFs animados útiles tienen menos de 30.

7. Stripping de EXIF y metadatos

Las fotos incluyen metadatos EXIF: ubicación GPS, modelo de cámara, timestamps, software. Si alguien sube una foto de su casa, podrías exponer su ubicación exacta si sirves la imagen original.

Todas las imágenes se re-encodifican a través de Sharp:

sharp(buffer)
  .rotate()              // Auto-rotates based on EXIF orientation
  .withMetadata({})      // Removes ALL EXIF, IPTC, XMP
  .resize(...)
  .webp()
  • .rotate() — corrige la orientación basándose en EXIF antes de eliminarlo
  • .withMetadata({}) — elimina todos los metadatos del output
  • El output es siempre WebP — no se preserva el formato original. Esto simplifica todo: un solo formato para servir, un solo Content-Type, una sola lógica de caché

8. Prevención de XSS por Content-Type sniffing

Los archivos procesados se suben a R2 con headers determinados por el servidor, nunca por el usuario:

ContentType: image/webp          // server-determined
ContentDisposition: inline
CacheControl: public, max-age=31536000, immutable

En Cloudflare CDN, añade Transform Rules:

X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'
X-Frame-Options: DENY

Un detalle importante: sirve los archivos desde un dominio CDN separado de tu API (ej: cdn.tuapp.com vs api.tuapp.com). Cualquier XSS en el dominio CDN no tiene acceso a las cookies ni tokens de la API. Es aislamiento de seguridad por dominio.

9. Semáforo de procesamiento concurrente

Si múltiples usuarios confirman uploads simultáneamente, tu servidor podría procesar muchas imágenes en paralelo, cada una descargando hasta 5MB + buffers de Sharp. Esto puede agotar la memoria.

La solución es un semáforo que limita los procesamientos simultáneos (3 en este caso):

private activeConfirms = 0;
private readonly MAX_CONCURRENT_CONFIRMS = 3;

Si hay 3 procesamientos en curso, el cuarto recibe 429 Too Many Requests. El semáforo se libera tanto en éxito como en error.

10. Transiciones de estado atómicas

El flujo de status de un upload es: pending → processing → confirmed (o deleted).

La transición pending → processing usa un update condicional:

// UPDATE uploads SET status = 'processing' WHERE id = ? AND status = 'pending'
const claimed = await atomicSetStatus(id, 'pending', 'processing');
if (!claimed) throw new ConflictException('Already being processed');

Si otro request ya movió el status, el update afecta 0 filas y se rechaza. Esto previene race conditions de double-confirm: múltiples solicitudes idénticas producen el mismo resultado que una sola.

Image processing por propósito

El procesamiento varía según el propósito del upload:

PropósitoOriginalThumbnail
avatarResize a 512px max, webp q85128px, webp q80
post_mediaSin tocar400px, webp q80

Para avatares, el original se sobrescribe con la versión procesada. El avatar anterior se borra de R2 para no acumular archivos huérfanos. El thumbnail se guarda como {original_key}_thumb.webp.

Orphan cleanup

Un cron job ejecuta cada 10 minutos:

  1. Encuentra uploads con status pending de más de 10 minutos
  2. Borra los objetos de R2 (best effort)
  3. Elimina los registros de la base de datos

Esto captura los casos donde el cliente obtuvo una presigned URL pero nunca subió el archivo, o subió pero nunca confirmó. Sin este cron, los archivos pendientes se acumularían indefinidamente.

Edge cases conocidos

Race condition de quota: Dos llamadas simultáneas a presign() podrían pasar ambas el check de pending count antes de que ninguna guarde. Resultado: 4 pendientes en vez de 3. Impacto: insignificante (un upload extra). El throttle HTTP (10/min) limita la explotación.

Avatares duplicados confirmados: Si dos uploads pendientes de avatar para el mismo usuario se confirman casi simultáneamente, ambos tienen éxito y uno queda huérfano en R2. El cleanup cron solo maneja status pending. Un cron periódico para duplicados confirmados puede resolverlo si se convierte en problema.

Ninguno de los dos lleva a acceso no autorizado, pérdida de datos o abuso de facturación.

Testing

Los tests unitarios con mocks del S3Client cubren todas las capas de seguridad:

  • Presign: genera URL, guarda registro, rechaza tipos no permitidos, devuelve 503 si R2 no está configurado
  • Bandwidth guards: rechaza al alcanzar max pending, quota diaria, límite horario por propósito
  • Confirm: DB lookup primero (sin llamar a R2 si ID no existe), verifica ownership, previene double confirm vía atomicSetStatus, valida magic bytes y rechaza spoofing, detecta GIF frame bombs, detecta decompression bombs, genera thumbnail y actualiza entidad, libera semáforo en error
  • Delete: verifica ownership, borra de R2, aplica fallback para avatares eliminados
  • Cleanup: encuentra uploads expirados, borra de R2 y DB

Los tests de guards son los más críticos — son tu red de seguridad contra abusos. Si un guard falla silenciosamente, estás expuesto.

Infrastructure (Cloudflare)

SettingValor recomendado
BucketTu bucket de uploads
Acceso públicoCustom domain con proxy (orange cloud)
R2.devDeshabilitado (nunca exponer)
CORSSolo tu dominio frontend, AllowedMethods: PUT
Cache RuleRespetar Cache-Control: immutable del origen
Response headersnosniff, CSP: default-src ‘none’, X-Frame-Options: DENY
DDoSActivado (default en dominios con proxy)

En desarrollo sin R2 configurado, el provider devuelve null y todos los endpoints responden con 503 Service Unavailable. La app arranca sin R2 — no hay dependencia hard.

Conclusión

Un sistema de upload en producción no es un endpoint. Es un pipeline con múltiples capas de seguridad: autenticación, scoping de URLs, bandwidth guards, validación de magic bytes, protección contra decompression bombs y GIF frame bombs, stripping de EXIF, prevención de XSS, semáforo de concurrencia, y transiciones atómicas de estado.

La mayoría de tutoriales se detienen en «genera una presigned URL y sube». Eso te funciona hasta que no funciona. Los ataques reales no aparecen en los ejemplos, y la diferencia entre un upload que funciona y uno que escala está en los detalles que este artículo cubre.

R2 te da el almacenamiento barato. Las capas de seguridad son tu trabajo.

Deja un comentario

Este sitio está protegido por reCAPTCHA y se aplican la política de privacidad y los términos de servicio de Google.