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:
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
import { ApiClient } from '../api/api-client.service';
|
import { ApiClient } from '../api/api-client.service';
|
||||||
const QRImage = require('qrcode');
|
const QRImage = require('qrcode');
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export class WebhookListener implements OnApplicationBootstrap {
|
|||||||
this.logger.log(`Webhook listener en puerto ${port}`);
|
this.logger.log(`Webhook listener en puerto ${port}`);
|
||||||
this.logger.log(`WHATSAPP_START → POST /whatsapp-start`);
|
this.logger.log(`WHATSAPP_START → POST /whatsapp-start`);
|
||||||
this.logger.log(`WHATSAPP_PDF → POST /whatsapp-pdf`);
|
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=.
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
private async handleQrPage(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||||
const expected = process.env.FUNNEL_API_KEY || '';
|
const expected = process.env.QR_TOKEN || '';
|
||||||
let provided = '';
|
const auth = (req.headers['authorization'] as string) || '';
|
||||||
try {
|
const pass = auth.startsWith('Basic ')
|
||||||
provided = new URL(req.url || '', 'http://localhost').searchParams.get('key') || '';
|
? Buffer.from(auth.slice(6), 'base64').toString('utf8').split(':').slice(1).join(':')
|
||||||
} catch {
|
: '';
|
||||||
/* url malformada */
|
if (!this.tokenValido(pass, expected)) {
|
||||||
}
|
|
||||||
if (!expected || provided !== expected) {
|
|
||||||
res
|
res
|
||||||
.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' })
|
.writeHead(401, {
|
||||||
.end('<h2>401</h2><p>Añade ?key=<FUNNEL_API_KEY> a la URL.</p>');
|
'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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +131,13 @@ export class WebhookListener implements OnApplicationBootstrap {
|
|||||||
'</head><body><div>' +
|
'</head><body><div>' +
|
||||||
cuerpo +
|
cuerpo +
|
||||||
'</div></body></html>';
|
'</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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export class WhatsappService implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
if (qr) {
|
if (qr) {
|
||||||
QRCode.generate(qr, { small: true });
|
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);
|
this.webhookListener.setQr(qr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user