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 :
- Pas de rate limiting sur la génération de liens
- Pas de limite de downloads par token
- Pas de validation IP du downloader
- Le token est exposé dans le HTML (risque de leak via extensions/screenshots)
- Pas de revocation de token avant expiration
- 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