# AUDITORÍA DE SEGURIDAD — Sistema Recarga.do / PagoExpress **Fecha:** 2026-08-12 **Alcance auditado:** `/var/www/html/recargas/` (motor de recargas), base de datos `recargas_2` (104.197.163.89) **Estado del sistema:** PRODUCCIÓN ACTIVA — 43,123 transacciones completadas, RD$ 5,527,395.94 procesados **Metodología:** revisión de código fuente + introspección de esquema + consultas de integridad sobre datos reales > Ningún secreto real aparece en este documento. Los valores sensibles se enmascaran como `********`. --- ## RESUMEN EJECUTIVO Se identificaron **6 hallazgos CRÍTICOS**, 5 ALTOS, 4 MEDIOS y 3 BAJOS. Tres de los hallazgos críticos permiten, de forma independiente, **pérdida directa de dinero**: | # | Hallazgo | Impacto | |---|---|---| | C-01 | Control de acceso desactivado (`AUTH_GUARD` en modo OBSERVE) | Cualquiera puede gastar el saldo de cualquier empresa | | C-02 | Débito de balance sin transacción ni bloqueo (lost update) | Doble recarga cobrada una sola vez | | C-03 | Sin idempotencia en operaciones financieras | Reintentos duplican recargas reales | | C-04 | Secretos de API en texto plano en base de datos | 610 credenciales de cliente comprometibles | | C-05 | Recarga entregada sin débito si falla el balance | Producto entregado gratis | | C-06 | Credenciales de producción en el webroot | Fuga total de BD y del proveedor telco | **Evidencia de que los riesgos ya se materializaron:** 96 empresas con saldo divergente entre las dos fuentes de verdad, 2 empresas con saldo disponible negativo, y solo **1 registro de débito en el libro mayor frente a 43,123 transacciones cobradas**. --- ## CRÍTICOS ### C-01 — Control de acceso deshabilitado: cualquiera puede gastar el saldo de cualquier empresa **Clasificación:** CRÍTICO · Broken Access Control / IDOR **Archivos:** `api/auth_guard.php:23`, `api/process-recharge.php:32-33,51-52,64-77` El gate de autorización existe pero está en modo observación permanente: ```php // api/auth_guard.php:22-24 if (!defined('AUTH_GUARD_ENFORCE')) { define('AUTH_GUARD_ENFORCE', false); // OBSERVE por defecto: no rompe nada } ``` Se verificó que **ningún archivo del sistema define `AUTH_GUARD_ENFORCE = true`**. En modo OBSERVE el guard solo escribe en `error_log` y devuelve; nunca corta la ejecución. Simultáneamente, `process-recharge.php` toma la identidad del **cuerpo de la petición**: ```php // api/process-recharge.php:32-33 $usuarioId = isset($input['usuario_id']) ? filter_var($input['usuario_id'], FILTER_VALIDATE_INT) : null; $empresaId = isset($input['empresa_id']) ? filter_var($input['empresa_id'], FILTER_VALIDATE_INT) : null; ``` Y ese `$empresaId` no autenticado es el que se usa para debitar (línea 431). **Explotación:** un `POST` sin cookie ni token con `{"empresa_id": N, "service_id": …, "identifier": "809…", "amount": 500}` ejecuta una recarga real cargada al saldo de la empresa `N`. Los IDs son secuenciales (1–369) y enumerables. **Alcance adicional:** de los 19 endpoints en `api/`, **solo `process-recharge.php` incluye el guard**. `get-transactions.php`, `reverse-transaction.php`, `pay-invoice.php`, `print-receipt.php`, `get-invoices.php`, `transaction-details.php` y los demás no tienen ninguna verificación compartida de pertenencia. **Corrección:** derivar la identidad exclusivamente del servidor (token validado), nunca del cuerpo. La API nueva lo implementa así por diseño; ver `ARCHITECTURE.md`. --- ### C-02 — Débito de balance sin transacción ni bloqueo: lost update **Clasificación:** CRÍTICO · Race condition / integridad financiera **Archivo:** `models/Transaction.php:484-552` `updateEmpresaBalance()` es un ciclo leer–calcular–escribir sin `BEGIN`, sin `SELECT … FOR UPDATE` y sin `UPDATE` condicional: ```php $balance = $this->db->fetch("SELECT id, saldo_actual, saldo_disponible FROM empresa_balances WHERE empresa_id = :empresa_id", …); $nuevoSaldo = $balance['saldo_actual'] - $monto; // cálculo en PHP $nuevoDisponible = $balance['saldo_disponible'] - $monto; $this->db->query("UPDATE empresa_balances SET saldo_actual = :saldo_actual, saldo_disponible = :saldo_disponible … WHERE empresa_id = :empresa_id", …); ``` Se confirmó por búsqueda que **no existe ni una sola llamada a `beginTransaction`, `commit`, `rollback` o `FOR UPDATE`** en `models/Transaction.php` ni en `classes/LimitValidator.php`. **Escenario de fallo concreto:** dos recargas de RD$100 concurrentes sobre un saldo de RD$1,000. Ambas leen 1000, ambas calculan 900, ambas escriben 900. Se entregaron RD$200 en recargas y se cobraron RD$100. Pérdida: RD$100 por colisión. **Evidencia en datos de producción:** 2 empresas presentan `saldo_disponible < 0`, resultado esperable de este patrón combinado con C-05. **Corrección:** débito atómico condicional dentro de una transacción: ```sql UPDATE empresa_balances SET saldo_disponible = saldo_disponible - :monto WHERE empresa_id = :id AND saldo_disponible >= :monto; -- 0 filas afectadas => saldo insuficiente, abortar ``` --- ### C-03 — Sin idempotencia en operaciones financieras **Clasificación:** CRÍTICO · Duplicación de transacciones **Archivo:** `api/process-recharge.php` (flujo completo) No existe `Idempotency-Key` ni ningún mecanismo de deduplicación de solicitudes. La columna `transacciones.xid` tiene índice `UNIQUE`, pero el `xid` lo asigna la respuesta de MidasRed **después** de ejecutar la recarga, por lo que no protege el reintento. **Escenario de fallo:** el cliente sufre un timeout de red a los 30s, reintenta el `POST` idéntico, y el usuario final recibe **dos recargas** mientras el cliente creía haber pedido una. Con el flujo de recuperación de las líneas 474-548, ambas pueden terminar en estado `completada`. Agravante: hay 367 transacciones en estado `pendiente`, precisamente el estado que induce a los clientes a reintentar. **Corrección:** `Idempotency-Key` obligatorio en todo endpoint que mueva dinero, con clave única en base de datos y respuesta cacheada. Implementado en la API nueva. --- ### C-04 — Secretos de API almacenados en texto plano **Clasificación:** CRÍTICO · Exposición de credenciales **Tablas:** `api_tokens.api_secret`, `clientes.api_secret` Distribución real de formatos (consultada sin revelar valores): | Tabla | Formato | Longitud | Registros | |---|---|---|---| | `api_tokens` | `sk_l…` (texto plano) | 72 | **347** | | `api_tokens` | `sk_a…` / `sk_1…` / `sk_5…` (texto plano) | 67 | 5 | | `api_tokens` | hex plano | 64 | 3 | | `api_tokens` | `$2y$…` (bcrypt) | 60 | 2 | | `clientes` | `sk_l…` (texto plano) | 72 | **263** | **610 secretos de cliente son recuperables directamente de la base de datos.** Cualquier lectura de la BD —inyección SQL, respaldo mal custodiado, acceso de un tercero a la instancia Cloud SQL, o el propio `DB_USER` compartido— entrega el control de las cuentas de todos los clientes. La columna `api_secret_encrypted` existe pero no se usa de forma consistente. **Corrección:** almacenar únicamente `hash` del secreto (bcrypt/argon2), mostrar el secreto en claro **una sola vez** en el momento de emisión, y rotar los 610 existentes. Ver `MIGRATION.md`. --- ### C-05 — La recarga se entrega aunque el débito falle **Clasificación:** CRÍTICO · Pérdida de dinero **Archivo:** `api/process-recharge.php:429-448` El orden de operaciones es: llamar al proveedor → si responde OK, marcar `completada` → **después** intentar debitar. Si el débito falla, solo se registra una advertencia: ```php $balanceUpdate = $transactionModel->updateEmpresaBalance($empresaId, $amount, 'debito'); if ($balanceUpdate) { … } else { $logger->warning('No se pudo actualizar balance de empresa', […]); // y continúa } ``` La respuesta al cliente sigue siendo `success: true`. **La recarga se entregó y nadie la pagó.** `updateEmpresaBalance()` devuelve `false` ante cualquier excepción de BD (línea 548-551), de modo que un fallo transitorio de conexión produce producto entregado sin cobro. **Corrección:** reservar el saldo **antes** de llamar al proveedor y confirmar/liberar después, todo bajo transacción. Ver `BUSINESS_RULES.md`. --- ### C-06 — Credenciales de producción en el webroot **Clasificación:** CRÍTICO · Exposición de secretos **Archivo:** `config/config.php` El archivo contiene, en texto plano y dentro del directorio servido por Apache: ```php define('DB_HOST', '********'); define('DB_NAME', '********'); define('DB_USER', '********'); define('DB_PASS', '********'); define('MIDASRED_USERNAME', '********'); define('MIDASRED_PASSWORD', '********'); define('MIDASRED_API_KEY', '********'); ``` Son las credenciales de la **base de datos de producción** y del **proveedor telco real** (MidasRed API 5.1 producción). Quien las obtenga puede ejecutar recargas reales facturables contra la cuenta de Recarga.do. La protección actual es una regla de `.htaccess` (`RewriteRule ^(config|classes|…)/ - [F,L]`). Un cambio de `AllowOverride`, una migración a Nginx o un fallo de configuración deja el archivo accesible. Además `CLAUDE.md` —también dentro del webroot— reproduce las mismas credenciales en claro. **Corrección:** mover a `.env` fuera del webroot (`/etc/recarga.do/api.env`, modo `0600`, dueño `www-data`), rotar la contraseña de BD y las credenciales de MidasRed, y purgar los valores de `CLAUDE.md`. --- ## ALTOS ### A-01 — `display_errors = 1` en un endpoint de producción `api/process-recharge.php:5-6` fuerza `error_reporting(E_ALL)` y `ini_set('display_errors', 1)`. Los avisos de PHP se mezclan con el JSON de respuesta (rompiendo el parseo del cliente) y filtran rutas absolutas y fragmentos de consulta. ### A-02 — Mensajes internos devueltos al cliente `api/process-recharge.php:205` incluye `'details' => $e->getMessage()` en una respuesta 500, y la línea 560 devuelve `$e->getMessage()` sin filtrar. Los mensajes de excepción de PDO exponen estructura de tablas y SQL. ### A-03 — TLS deshabilitado, incluso contra el proveedor telco real `classes/MidasRedAPI.php:178` desactiva la verificación de certificado en **todas** las llamadas al proveedor de producción: ```php curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); ``` Por ese canal viajan las credenciales de MidasRed y las órdenes de recarga facturables. Un atacante con posición de red puede interceptarlas o alterar respuestas (por ejemplo, convertir un fallo en `cod: '00'`). El mismo defecto aparece en `index.php` (≈ líneas 211, 424, 510, 855) al invocar `recarga.do/master/api/empresa_details.php` con `verify_peer = false`. Agravante relacionado: `CURLOPT_TIMEOUT` está fijado en **180 segundos** (línea 179) y `CURLOPT_CONNECTTIMEOUT` en 60. Un proveedor lento retiene el worker tres minutos y empuja al cliente a reintentar, lo que activa directamente C-03. ### A-04 — `JWT_SECRET` derivable del código fuente Definido como `'sk_jwt_' . hash('sha256', '')` en `login_jwt.php` y replicado en `index.php`. Cualquiera con acceso al código —incluidos los respaldos en el árbol— puede firmar tokens válidos para cualquier `empresa_id`. La verificación de firma añadida el 2026-06-18 detiene a un atacante externo, pero no a quien tenga el fuente. ### A-05 — Hashes de secreto corruptos 7 registros de `empresas.api_secret_hash` tienen longitudes de 16, 18, 27 y 34 caracteres. Un hash bcrypt válido siempre mide 60. Esas 7 empresas tienen credenciales inutilizables o truncadas; debe determinarse si alguna ruta de comparación las acepta de forma laxa. --- ## MEDIOS ### M-01 — Doble fuente de verdad del saldo, ya divergente `empresas.saldo_actual` y `empresa_balances.saldo_actual` almacenan el mismo dato. **96 empresas presentan divergencia > RD$0.01.** No hay forma automatizada de saber cuál es correcta. ### M-02 — Libro mayor prácticamente vacío para débitos `movimientos_balance` contiene 809 `recarga`, 122 `comision` y **1 solo `debito`**, frente a 43,123 transacciones completadas. `updateEmpresaBalance()` nunca inserta el movimiento correspondiente. **No existe trazabilidad contable de los cobros ni posibilidad real de conciliación.** ### M-03 — Validación de horario deshabilitada dejando código muerto `api/process-recharge.php:122-159`: el bloque `HorarioValidator` está comentado en su totalidad y sustituido por un `error_log`. El comentario original documentaba además un `fail-open` explícito ante excepción. `classes/HorarioValidator.php` queda como código muerto. ### M-04 — Bloqueo artificial de 0.5 s por transacción `api/process-recharge.php:452` y `:521` ejecutan `usleep(500000)` esperando a que un webhook escriba `recibo_empresa_id`. Cada recarga retiene un worker de PHP-FPM medio segundo de más, limitando la concurrencia máxima del pool sin razón funcional. --- ## BAJOS ### B-01 — Colaciones mezcladas `transacciones` usa `utf8mb4`; `empresas`, `empresa_balances` y `movimientos_balance` usan `utf8/utf8_unicode_ci`. Los JOIN entre ellas fuerzan conversión y descartan índices. Existe un `fix_collation.php` en el árbol, señal de que ya causó incidentes. ### B-02 — Valores de ENUM inválidos 8 registros de `empresas` tienen `ambiente = ''`, valor que no pertenece al ENUM `('production','development')`. El enrutamiento producción/sandbox de esas empresas queda indefinido. ### B-03 — Archivos de prueba y diagnóstico servibles El árbol contiene ~40 archivos `test_*.php`, `debug*.php`, `check_*.php`, `monitor_token.php` y `security_monitor.php` accesibles por HTTP. `debug.php` (117 KB) expone estado de sistema, configuración y conectividad. --- ## INTEGRIDAD DE DATOS OBSERVADA Consultas ejecutadas sobre producción el 2026-08-12: | Verificación | Resultado | |---|---| | Empresas con `saldo_disponible < 0` | **2** | | Divergencia `empresas.saldo_actual` vs `empresa_balances.saldo_actual` | **96 empresas** | | Transacciones `completada` | 43,123 (RD$ 5,527,395.94) | | Movimientos tipo `debito` en el libro mayor | **1** | | Transacciones atascadas en `pendiente` | 367 (RD$ 43,381.00) | | Transacciones sin `empresa_id` | 166 | --- ## PLAN DE REMEDIACIÓN PRIORIZADO | Orden | Acción | Hallazgo | Riesgo de no hacerlo | |---|---|---|---| | 1 | Débito atómico condicional + transacción de BD | C-02, C-05 | Pérdida de dinero continua | | 2 | Identidad derivada del servidor, nunca del body | C-01 | Robo de saldo entre empresas | | 3 | `Idempotency-Key` obligatorio | C-03 | Recargas duplicadas | | 4 | Mover secretos a `.env` fuera del webroot + rotar | C-06 | Fuga total | | 5 | Hashear los 610 secretos de cliente + rotar | C-04 | Suplantación de clientes | | 6 | Registrar todo débito en `movimientos_balance` | M-02 | Imposible conciliar | | 7 | Unificar la fuente de verdad del saldo | M-01 | Divergencia creciente | | 8 | `display_errors=0` + errores normalizados | A-01, A-02 | Fuga de estructura interna | Los puntos 1, 2, 3 y 6 quedan resueltos **por diseño** en la API nueva de `/var/www/recarga.do/api/`. Los puntos 4, 5, 7 y 8 requieren intervención sobre el sistema existente y están documentados en `MIGRATION.md`. --- ## NOTA DE ALCANCE - **HECHO CONFIRMADO:** todo lo citado con archivo y número de línea, y todas las cifras de la tabla de integridad (consultadas directamente contra producción). - **REQUIERE VALIDACIÓN:** el impacto económico acumulado de C-02 y C-05 no se cuantificó; exige conciliar `transacciones` contra los estados de cuenta reales de MidasRed, trabajo que no se ejecutó en esta fase. - **NO CONFIRMADO:** si alguna de las 7 empresas con hash corrupto (A-05) puede autenticarse; requiere revisar cada ruta de comparación de credenciales. - No se modificó ningún archivo de `/var/www/html/recargas/` durante esta auditoría.