From ce1d1396e0f272d256ec8f483ce23e5b1ab3d593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edrei=20T=C3=A9llez?= Date: Fri, 12 Jun 2026 17:19:14 -0600 Subject: [PATCH] SEC-001: migrar HcaptchaGuard a TurnstileGuard con siteverify Cloudflare. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bypass solo en desarrollo; pruebas y producción validan header turnstile vía HTTPS nativo. Co-authored-by: Cursor --- package-lock.json | 11 ----- package.json | 1 - src/auth/auth.controller.ts | 8 ++-- src/guards/hcaptcha.guard.ts | 37 -------------- src/guards/turnstile.guard.ts | 80 +++++++++++++++++++++++++++++++ src/usuario/usuario.controller.ts | 4 +- 6 files changed, 86 insertions(+), 55 deletions(-) delete mode 100644 src/guards/hcaptcha.guard.ts create mode 100644 src/guards/turnstile.guard.ts diff --git a/package-lock.json b/package-lock.json index 107f5c5..884e7d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,6 @@ "class-validator": "^0.13.2", "csvtojson": "^2.0.10", "dotenv": "^16.0.3", - "hcaptcha": "^0.1.1", "moment": "^2.29.4", "mysql2": "^2.3.3", "nodemailer": "^6.7.8", @@ -5049,11 +5048,6 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, - "node_modules/hcaptcha": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/hcaptcha/-/hcaptcha-0.1.1.tgz", - "integrity": "sha512-iMrDmH2VpIEKOrcKWidVjI89FdDKTEdZ7PfPWkP27sTazIIkob8YfdY2ezaufAnWBiUUcvzsn0qF+dyXtBH2Vw==" - }, "node_modules/hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", @@ -13436,11 +13430,6 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, - "hcaptcha": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/hcaptcha/-/hcaptcha-0.1.1.tgz", - "integrity": "sha512-iMrDmH2VpIEKOrcKWidVjI89FdDKTEdZ7PfPWkP27sTazIIkob8YfdY2ezaufAnWBiUUcvzsn0qF+dyXtBH2Vw==" - }, "hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", diff --git a/package.json b/package.json index c62b809..2d3cc27 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "class-validator": "^0.13.2", "csvtojson": "^2.0.10", "dotenv": "^16.0.3", - "hcaptcha": "^0.1.1", "moment": "^2.29.4", "mysql2": "^2.3.3", "nodemailer": "^6.7.8", diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 0a81ec6..ed93d53 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Serealize } from '../interceptors/serialize.interceptor'; -import { HcaptchaGuard } from '../guards/hcaptcha.guard'; +import { TurnstileGuard } from '../guards/turnstile.guard'; import { AuthService } from './auth.service'; import { LoginAdminDto } from './dto/input/login-admin.dto'; import { LoginOperadorDto } from './dto/input/login-operador.dto'; @@ -15,7 +15,7 @@ export class AuthController { constructor(private authService: AuthService) {} @Serealize(AuthTokenOutputDto) - @UseGuards(HcaptchaGuard) + @UseGuards(TurnstileGuard) @Post('login-admin') @ApiOperation({ description: 'Login del admin.' }) @ApiBody({ @@ -27,7 +27,7 @@ export class AuthController { } @Serealize(AuthTokenOutputDto) - @UseGuards(HcaptchaGuard) + @UseGuards(TurnstileGuard) @Post('login-operador') @ApiOperation({ description: 'Login del operador.' }) @ApiBody({ @@ -45,7 +45,7 @@ export class AuthController { } @Serealize(AuthTokenOutputDto) - @UseGuards(HcaptchaGuard) + @UseGuards(TurnstileGuard) @Post('login-usuario') @ApiOperation({ description: 'Login del usuario.' }) @ApiBody({ diff --git a/src/guards/hcaptcha.guard.ts b/src/guards/hcaptcha.guard.ts deleted file mode 100644 index f85cd5f..0000000 --- a/src/guards/hcaptcha.guard.ts +++ /dev/null @@ -1,37 +0,0 @@ -const { verify } = require('hcaptcha'); -import { - BadRequestException, - CanActivate, - ExecutionContext, - ForbiddenException, - Injectable, - InternalServerErrorException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Observable } from 'rxjs'; - -@Injectable() -export class HcaptchaGuard implements CanActivate { - constructor(private configService: ConfigService) {} - - canActivate( - context: ExecutionContext, - ): boolean | Promise | Observable { - const secret = this.configService.get('HCAPTCHA_KEY'); - const token = context.switchToHttp().getRequest().headers.hcaptcha; - - if (this.configService.get('STATE') !== 'produccion') return true; - if (!token) - throw new BadRequestException('No se mando un token de hcaptcha.'); - return verify(secret, token) - .then((data) => { - if (data.success === true) return true; - else throw new ForbiddenException('El token de hcaptcha no es válido.'); - }) - .catch((err) => { - throw new InternalServerErrorException( - 'Ocurrió un error con el hcaptcha.', - ); - }); - } -} diff --git a/src/guards/turnstile.guard.ts b/src/guards/turnstile.guard.ts new file mode 100644 index 0000000..8edac59 --- /dev/null +++ b/src/guards/turnstile.guard.ts @@ -0,0 +1,80 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as https from 'https'; +import { Observable } from 'rxjs'; + +function verifyTurnstile( + secret: string, + token: string, +): Promise<{ success: boolean }> { + const body = new URLSearchParams({ secret, response: token }).toString(); + + return new Promise((resolve, reject) => { + const req = https.request( + { + hostname: 'challenges.cloudflare.com', + path: '/turnstile/v0/siteverify', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(body), + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(err); + } + }); + }, + ); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +@Injectable() +export class TurnstileGuard implements CanActivate { + constructor(private configService: ConfigService) {} + + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + if (this.configService.get('STATE') === 'desarrollo') return true; + + const token = context.switchToHttp().getRequest().headers.turnstile; + if (!token) + throw new BadRequestException('No se envió un token de turnstile.'); + + const secret = this.configService.get('TURNSTILE_SECRET_KEY'); + return verifyTurnstile(secret, token) + .then((data) => { + if (data.success === true) return true; + throw new ForbiddenException('El token de turnstile no es válido.'); + }) + .catch((err) => { + if ( + err instanceof ForbiddenException || + err instanceof BadRequestException + ) + throw err; + throw new InternalServerErrorException( + 'Ocurrió un error con turnstile.', + ); + }); + } +} diff --git a/src/usuario/usuario.controller.ts b/src/usuario/usuario.controller.ts index 09c53fd..ede9147 100644 --- a/src/usuario/usuario.controller.ts +++ b/src/usuario/usuario.controller.ts @@ -17,7 +17,7 @@ import { ApiTags, } from '@nestjs/swagger'; import { Serealize } from '../interceptors/serialize.interceptor'; -import { HcaptchaGuard } from '../guards/hcaptcha.guard'; +import { TurnstileGuard } from '../guards/turnstile.guard'; import { UsuarioService } from './usuario.service'; import { ValidarUsuarioService } from '../validar-usuario/validar-usuario.service'; import { Operador } from '../operador/entity/operador.entity'; @@ -39,7 +39,7 @@ export class UsuarioController { @Serealize(MessageOutputDto) @Post('registrar') - @UseGuards(HcaptchaGuard) + @UseGuards(TurnstileGuard) @ApiOperation({ description: 'Registro de usuario.' }) @ApiBody({ description: 'Variables que necesita el endpoint.',