Seguridad /qr: HTTP Basic Auth con QR_TOKEN dedicado (no secreto en la URL)

Review de seguridad: autenticar por query string filtra el secreto (logs,
historial, Referer) y usaba la misma FUNNEL_API_KEY que autoriza la API.
Ahora /qr usa HTTP Basic (credencial en cabecera), un QR_TOKEN dedicado
distinto de FUNNEL_API_KEY, comparación en tiempo constante (timingSafeEqual
sobre hashes) y Referrer-Policy: no-referrer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-09 16:51:46 +02:00
parent a43e7a77be
commit a740d08863
2 changed files with 32 additions and 14 deletions

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import * as http from 'http';
import * as crypto from 'crypto';
import { ApiClient } from '../api/api-client.service';
const QRImage = require('qrcode');
@@ -21,7 +22,7 @@ export class WebhookListener implements OnApplicationBootstrap {
this.logger.log(`Webhook listener en puerto ${port}`);
this.logger.log(`WHATSAPP_START → POST /whatsapp-start`);
this.logger.log(`WHATSAPP_PDF → POST /whatsapp-pdf`);
this.logger.log(`QR vinculación → GET /qr?key=FUNNEL_API_KEY`);
this.logger.log(`QR vinculación → GET /qr (HTTP Basic, contraseña = QR_TOKEN)`);
});
}
@@ -75,19 +76,30 @@ export class WebhookListener implements OnApplicationBootstrap {
});
}
// Página de vinculación: muestra el QR de Baileys como imagen escaneable. Protegida por ?key=.
private async handleQrPage(req: http.IncomingMessage, res: http.ServerResponse) {
const expected = process.env.FUNNEL_API_KEY || '';
let provided = '';
try {
provided = new URL(req.url || '', 'http://localhost').searchParams.get('key') || '';
} catch {
/* url malformada */
// Comparación en tiempo constante (sobre hashes, sin filtrar longitud).
private tokenValido(a: string, b: string): boolean {
if (!a || !b) return false;
const ha = crypto.createHash('sha256').update(a).digest();
const hb = crypto.createHash('sha256').update(b).digest();
return crypto.timingSafeEqual(ha, hb);
}
if (!expected || provided !== expected) {
// Página de vinculación: muestra el QR de Baileys como imagen escaneable.
// Auth por cabecera (HTTP Basic, contraseña = QR_TOKEN), nunca por query string.
private async handleQrPage(req: http.IncomingMessage, res: http.ServerResponse) {
const expected = process.env.QR_TOKEN || '';
const auth = (req.headers['authorization'] as string) || '';
const pass = auth.startsWith('Basic ')
? Buffer.from(auth.slice(6), 'base64').toString('utf8').split(':').slice(1).join(':')
: '';
if (!this.tokenValido(pass, expected)) {
res
.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' })
.end('<h2>401</h2><p>Añade ?key=&lt;FUNNEL_API_KEY&gt; a la URL.</p>');
.writeHead(401, {
'WWW-Authenticate': 'Basic realm="Reformix QR", charset="UTF-8"',
'Content-Type': 'text/html; charset=utf-8',
'Referrer-Policy': 'no-referrer',
})
.end('<h2>401</h2><p>Usuario: cualquiera · Contraseña: el QR_TOKEN.</p>');
return;
}
@@ -119,7 +131,13 @@ export class WebhookListener implements OnApplicationBootstrap {
'</head><body><div>' +
cuerpo +
'</div></body></html>';
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }).end(html);
res
.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
})
.end(html);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@@ -136,7 +136,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
if (qr) {
QRCode.generate(qr, { small: true });
console.log('\n📲 Escanea este QR con WhatsApp (o abre /qr?key=FUNNEL_API_KEY)\n');
console.log('\n📲 Escanea este QR con WhatsApp (o abre la página /qr, protegida con QR_TOKEN)\n');
this.webhookListener.setQr(qr);
}