Explorer
KNOW-PAT-058

Tokens de téléchargement sécurisés - Génération, expiration, rate limiting

Domaine
cybersecu
Type
pattern
Priorité
P2

Parent : [[INDEX-CYBERSECU]]

Tokens de téléchargement sécurisés - Génération, expiration, rate limiting

Problème

Comment générer des liens de téléchargement directs sécurisés, temporaires, et protégés contre le partage abusif ? L'archive Ygg contient un système de génération de tokens avec nanoid(32) et expiration 24h, mais sans rate limiting ni protection contre la génération massive.

Contexte archive Ygg

  • Fichier : 01_SOURCE_CODE/02g_debrideur/controllers/downloadController.js
  • Technique : nanoid(32) pour générer des tokens uniques
  • Expiration : 24h (new Date(Date.now() + 24 * 60 * 60 * 1000))
  • Stockage : MariaDB avec compteur de téléchargements
  • Faille : Pas de rate limiting sur /downloads/generate, pas de validation IP, pas de max downloads

Technique de l'archive Ygg

// downloadController.js — Archive Ygg (AVEC FAILLES)
exports.generateLink = async (req, res) => {
  const { torrentId, fileId } = req.body;

  // Vérification propriétaire (correct)
  const torrent = await db.queryOne(
    'SELECT * FROM torrents WHERE id = ?', [torrentId]
  );
  if (!torrent || torrent.user_id !== req.session.userId) {
    req.flash('error', 'Accès refusé.');
    return res.redirect('/torrents');
  }

  // Génération token (32 caractères nanoid)
  const token = nanoid(32);
  const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h

  await db.insert(
    `INSERT INTO downloads (id, user_id, torrent_id, file_id, token, expires_at, download_count, created_at) 
     VALUES (?, ?, ?, ?, ?, ?, 0, NOW())`,
    [uuidv4(), req.session.userId, torrentId, fileId, token, expiresAt]
  );

  // LIEN DIRECT EXPOSE
  res.render('downloads/link', { 
    link: `${req.protocol}://${req.get('host')}/downloads/${token}`
  });
};

// Route publique SANS rate limiting
router.get('/:token', downloadController.download);

Failles identifiées :

  1. Pas de rate limiting sur la génération de liens
  2. Pas de limite de downloads par token
  3. Pas de validation IP du downloader
  4. Le token est exposé dans le HTML (risque de leak via extensions/screenshots)
  5. Pas de revocation de token avant expiration
  6. Pas de watermarking des fichiers

Solution protectif — Tokens de téléchargement sécurisés

1. Génération de tokens avec contraintes

// secureDownload.js — Tokens sécurisés
const crypto = require('crypto');
const redis = require('./redis');

class SecureDownloadToken {
  constructor(config = {}) {
    this.tokenLength = config.tokenLength || 32;
    this.maxLifetime = config.maxLifetime || 24 * 60 * 60 * 1000; // 24h
    this.maxDownloads = config.maxDownloads || 3; // Max 3 downloads/token
    this.ipBinding = config.ipBinding || false;
    this.requireAuth = config.requireAuth || true;
  }

  // Générer un token avec contraintes
  async generate(userId, fileId, constraints = {}) {
    // Rate limiting : max 10 tokens/heure par utilisateur
    const rateKey = `token_gen:${userId}`;
    const genCount = await redis.incr(rateKey);
    await redis.expire(rateKey, 3600);
    
    if (genCount > 10) {
      throw new Error('Rate limit exceeded: max 10 tokens/hour');
    }

    // Token cryptographique fort
    const token = crypto.randomBytes(this.tokenLength).toString('base64url');
    
    // Metadata du token
    const metadata = {
      userId,
      fileId,
      createdAt: Date.now(),
      expiresAt: Date.now() + this.maxLifetime,
      maxDownloads: constraints.maxDownloads || this.maxDownloads,
      downloadCount: 0,
      boundIp: constraints.ipBinding ? req.ip : null,
      revoked: false,
      watermark: constraints.watermark || null
    };

    // Stocker dans Redis (pas en DB lente)
    await redis.setex(`dl_token:${token}`, this.maxLifetime / 1000, JSON.stringify(metadata));
    
    // Audit log
    auditLog.info(`Token generated`, { userId, fileId, token: token.slice(0, 8) + '...' });
    
    return token;
  }

  // Valider et consommer un token
  async validate(token, downloaderIp) {
    const raw = await redis.get(`dl_token:${token}`);
    if (!raw) {
      throw new Error('Token invalide ou expiré');
    }

    const metadata = JSON.parse(raw);
    
    // Vérifier expiration
    if (Date.now() > metadata.expiresAt) {
      await redis.del(`dl_token:${token}`);
      throw new Error('Token expiré');
    }

    // Vérifier revocation
    if (metadata.revoked) {
      throw new Error('Token révoqué');
    }

    // Vérifier IP binding
    if (metadata.boundIp && metadata.boundIp !== downloaderIp) {
      auditLog.warn(`IP mismatch for token`, { 
        expected: metadata.boundIp, 
        actual: downloaderIp,
        token: token.slice(0, 8) + '...'
      });
      throw new Error('Token non valide pour cette IP');
    }

    // Vérifier max downloads
    if (metadata.downloadCount >= metadata.maxDownloads) {
      await redis.del(`dl_token:${token}`);
      throw new Error('Nombre maximum de téléchargements atteint');
    }

    // Incrémenter le compteur
    metadata.downloadCount++;
    const ttl = Math.ceil((metadata.expiresAt - Date.now()) / 1000);
    await redis.setex(`dl_token:${token}`, ttl, JSON.stringify(metadata));

    return metadata;
  }

  // Révoquer un token (par l'utilisateur ou admin)
  async revoke(token, reason) {
    const raw = await redis.get(`dl_token:${token}`);
    if (!raw) return false;
    
    const metadata = JSON.parse(raw);
    metadata.revoked = true;
    metadata.revokedAt = Date.now();
    metadata.revokeReason = reason;
    
    const ttl = Math.ceil((metadata.expiresAt - Date.now()) / 1000);
    await redis.setex(`dl_token:${token}`, ttl, JSON.stringify(metadata));
    
    auditLog.info(`Token revoked`, { token: token.slice(0, 8) + '...', reason });
    return true;
  }
}

2. Route de téléchargement protégée

// downloads.js — Route sécurisée
const express = require('express');
const router = express.Router();
const rateLimit = require('express-rate-limit');

// Rate limiting strict sur le téléchargement
const downloadLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10, // 10 downloads par IP
  message: 'Too many downloads from this IP'
});

router.get('/:token', downloadLimiter, async (req, res) => {
  try {
    const { token } = req.params;
    const downloaderIp = req.ip;

    // Valider le token
    const tokenManager = new SecureDownloadToken();
    const metadata = await tokenManager.validate(token, downloaderIp);

    // Récupérer le fichier
    const filePath = await getFilePath(metadata.fileId);
    
    if (!filePath || !fs.existsSync(filePath)) {
      return res.status(404).send('Fichier introuvable');
    }

    // Appliquer le watermark si configuré
    if (metadata.watermark) {
      const watermarkedPath = await applyWatermark(filePath, metadata.watermark);
      return res.download(watermarkedPath, path.basename(filePath));
    }

    // Stream avec rate limiting
    const stream = fs.createReadStream(filePath);
    res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
    stream.pipe(res);

    // Log
    auditLog.info(`Download completed`, {
      token: token.slice(0, 8) + '...',
      fileId: metadata.fileId,
      ip: downloaderIp,
      count: metadata.downloadCount
    });

  } catch (error) {
    if (error.message.includes('expiré') || error.message.includes('invalide')) {
      return res.status(410).send('Lien expiré ou invalide');
    }
    res.status(500).send('Erreur serveur');
  }
});

3. Watermarking des fichiers (option avancée)

// watermark.js — Watermark invisible
const { createCanvas, loadImage } = require('canvas');

async function applyInvisibleWatermark(filePath, userId) {
  // Lire l'image
  const image = await loadImage(filePath);
  const canvas = createCanvas(image.width, image.height);
  const ctx = canvas.getContext('2d');
  
  ctx.drawImage(image, 0, 0);
  
  // Encoder le userId dans les bits de poids faible des pixels
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const data = imageData.data;
  
  const binary = userId.toString(2).padStart(32, '0');
  
  for (let i = 0; i < binary.length; i++) {
    // Modifier le LSB du canal bleu
    data[i * 4 + 2] = (data[i * 4 + 2] & 0xFE) | parseInt(binary[i]);
  }
  
  ctx.putImageData(imageData, 0, 0);
  
  return canvas.toBuffer('image/png');
}

4. Détection de partage abusif

# detect_abuse.py — Détecter le partage de tokens
import redis
from collections import defaultdict

def detect_token_abuse():
    r = redis.Redis()
    
    # Récupérer tous les tokens actifs
    tokens = r.keys('dl_token:*')
    
    ip_counts = defaultdict(int)
    
    for token_key in tokens:
        metadata = json.loads(r.get(token_key))
        
        # Détecter les tokens avec beaucoup d'IPs différentes
        # (si on track les IPs de téléchargement)
        if 'downloadIps' in metadata:
            unique_ips = len(set(metadata['downloadIps']))
            if unique_ips > 3:
                print(f"ALERT: Token {token_key} used from {unique_ips} IPs")
                # Révoquer automatiquement
                r.delete(token_key)

Checklist de validation

  • Tokens cryptographiques forts (crypto.randomBytes, pas Math.random)
  • Expiration automatique (Redis TTL)
  • Rate limiting sur la génération (max 10/h)
  • Rate limiting sur le téléchargement (max 10/15min par IP)
  • Limite de downloads par token (max 3)
  • IP binding optionnel
  • Révocation manuelle et automatique possible
  • Audit log de chaque génération et téléchargement
  • Pas d'exposition du token dans le HTML/URL visible
  • Watermarking optionnel pour tracabilité
  • Validation du chemin de fichier (pas de path traversal)
  • Streaming avec rate limiting (pas de chargement complet en mémoire)

Anti-patterns associés

  • KNOW-ANT-008 — Unrestricted download token generation

Comment se prot[ée]ger

  • Tokens [a] usage unique (single-use)
  • Expiration courte (60s max)
  • Signature HMAC avec cl[ée] rotative
  • Rate limiting sur la g[ée]n[ée]ration
  • IP binding optionnel

Références

  • Archive Ygg : 01_SOURCE_CODE/02g_debrideur/controllers/downloadController.js
  • OWASP : Insecure Direct Object References
  • nanoid : https://github.com/ai/nanoid