Guía interna · Ingeniería Mesa247 · agosto 2026
Criterio: cómo escribimos código en Mesa247
Para quienes mantienen l-home-restaurantes, l-parallevar, l-widgetexperiencias y l-librodereservas. No es un manual de Laravel ni de React: es lo que decidimos que se hace y no se hace aquí, con ejemplos sacados de nuestro propio código.
La sintaxis la sabemos. Lo que nos ha costado incidentes, noches y clientes es el criterio: qué se mergea y qué no, dónde va un secreto, cómo se responde un error, qué pasa a las 7 de la noche con las fechas, y qué significa "listo". Esta guía fija eso.
Repos 4 · Laravel 5.4/5.5 + React 15
Documento interno · no publicar fuera del equipo
Fuente auditoría de código del 16-ago-2026
La guía
Por qué esta guía, por qué ahora
En julio y agosto de 2026 pasamos por un incidente serio: tokens impresos en HTML público, endpoints que emitían sesiones sin contraseña, URLs crudas de Cloud Run llamadas desde el navegador saltándose el balanceador, secretos commiteados en repos, y un merge que despliega a producción sin que nadie lo revise. Ninguna de esas cosas fue un error de sintaxis. Fueron decisiones pequeñas, repetidas durante años, que nadie escribió como regla.
Esta guía hace tres cosas: (1) fija las reglas del equipo, (2) muestra los vicios concretos que encontramos en nuestro código —con ruta y línea, para que nadie piense que es teoría— y cómo se hace bien, y (3) da herramientas para que el criterio no dependa de la memoria: un checklist de PR, una definición de listo, y reglas para trabajar con IA sin bajar la vara.
Cómo usarla
Léela una vez completa (40 minutos). Después, vive en dos lugares: el checklist de PR se pega en la plantilla de Pull Request de cada repo, y las reglas van en el CLAUDE.md / CONTRIBUTING.md. Cuando revises código ajeno y encuentres un vicio de la lista, enlaza a su sección en lugar de discutirlo de nuevo.
Cómo trabajamos: el flujo de un cambio
El mismo flujo para un fix de una línea y para una funcionalidad de dos semanas. Lo que cambia es el tamaño, no los pasos.
1 · RamaUna rama por cambio, desde main actualizado. Nombre que diga qué es: fix/reserva-tz-lima, feat/widget-multipais. Nunca se trabaja directo en la rama de deploy.
2 · CommitsPequeños, con mensaje en imperativo que explica el porqué. "fix" o "cambios" no es un mensaje. Si necesitas "y", son dos commits.
3 · LocalTests en verde, linter limpio, probado en local o staging. Nunca "lo pruebo en prod".
4 · PRDescripción con qué/por qué/cómo probarlo, checklist marcado, capturas si hay UI. Tamaño revisable (< 400 líneas de diff; si es más, se parte).
5 · RevisiónOtra persona lee todo el diff con el
checklist. Comenta, pide cambios o aprueba. El autor responde cada comentario: cambia o argumenta.
6 · CI verdeTests + análisis estático + build de la imagen. Rojo no se mergea, punto. Nadie desactiva un test para que pase.
7 · Merge y deploySquash o merge commit; el deploy es un paso explícito y observado (¿logs limpios? ¿/health responde? ¿la versión que corre es la que mergeé?). Se sabe cómo hacer rollback antes de mergear.
8 · CierreTicket cerrado con enlace al PR. Si hubo una decisión no obvia, va al DECISIONES.md del repo.
Merge = deploy: la trampa de esta casa
En l-widgetexperiencias, l-parallevar y otros, mergear a la rama de deploy dispara Cloud Build y sale a producción sin aprobación humana. Mientras eso siga así, la revisión del PR es la aprobación de deploy. Objetivo del plan: separar los dos pasos (deploy con aprobación o desde tag) y correr tests en la CI antes de construir la imagen.
Los vicios
Qué encontramos en nuestro código
Auditamos los repos el 16 de agosto de 2026 en dos rondas (primero los cuatro repos PHP/React; luego siete analistas con dos modelos sobre m-incidente, m-b-core, los repos de operación, web202101/gateway/apisocial y una segunda pasada con lentes nuevas), solo lectura, con evidencia por archivo y línea. Lo que sigue no es una lista de bugs: son hábitos, cosas que aparecen decenas o cientos de veces y en más de un repo. Están ordenadas por el daño que causan. Cada una trae cómo se ve, por qué duele (con lo que ya nos pasó cuando aplica), cómo se hace bien y la regla de una línea que sale de ahí.
13repos auditados: 8 de aplicación (Laravel 5.4/5.5/8, CodeIgniter 2.1, React 15, Python 3.12) + 5 de operación e incidente
1repo con CI real (m-b-core); los otros 12 despliegan sin tests, o no despliegan desde el repo
25tests reales en total (240.000 líneas solo en el backend)
570Carbon::now() sin zona horaria en el backend
92whereRaw con variables interpoladas en el SQL
≥40lugares donde un token de sesión llega al navegador (HTML, JSON o localStorage)
Convención: [home] = l-home-restaurantes (API/backoffice), [pll] = l-parallevar, [exp] = l-widgetexperiencias, [libro] = l-librodereservas. Los valores de secretos nunca se copian; solo dónde están.
A · Secretos y configuración
1 · Secretos dentro del repositorio
alta[home] [exp] [pll] [libro]
Así está
[home] un JSON de service account de Google con private_key en la raíz, cinco .pem de APNs con clave privada en app/Notifications/, y un dump.rdb de Redis de 5 MB, todos versionados. Además vendor/ commiteado (24.964 archivos) y el .dockerignore se llama .docker-ignore, así que todo eso entra también a la imagen de producción.l-home-restaurantes: GoogleBookingAPI-*.json · app/Notifications/apns-*.pem · dump.rdb · .docker/Dockerfile:74
[exp] los master tokens "robot" de PE y CL escritos en el controlador base y elegidos por host; los merchant IDs de Kushki (incluido el privado) y una URL de UAT en config/app.php; .env.example con una pk_live de Culqi y APP_DEBUG=true.l-widgetexperiencias: app/Http/Controllers/Controller.php:50 · config/app.php:168-170 · .env.example:4,15
[pll] el secreto X-Auth-Sguard hardcodeado en dos controladores mientras .env.example declara la variable vacía. [libro] claves de Pusher y DSN de Sentry como constantes del bundle.l-parallevar (rama de deploy): ReservationController.php:862, GiftcardController.php:510 · l-librodereservas: src/Config/Origins.js:90-92
Por qué duele
Un secreto en git está en cada clon, cada fork, cada laptop y cada imagen Docker. Rotarlo obliga a reescribir historia y redesplegar todo. Con la cantidad de accesos que ha tenido esta base de código, hay que asumir que están comprometidos.
Así se hace
// config/services.php
'mesa' => [
'robot_token' => env('MESA_ROBOT_TOKEN'),
],
// en código, nunca env():
$token = config('services.mesa.robot_token');
Los valores viven en Secret Manager y llegan por --set-secrets (como ya hace MASTER_API_TOKEN en parallevar). .env.example solo con nombres. Llaves .pem montadas como secreto en un archivo, fuera del repo. gitleaks en la CI para que no vuelva a pasar.
Regla. Un secreto entra por variable de entorno, se lee una sola vez en config/ y jamás toca git; si tocó git, se rota.
2 · Tokens de terceros y de la propia API escritos a mano y duplicados
alta[home]
Así está
Diez o más tokens literales en código: Nubefact, Cabify, apis.net.pe (el mismo token copiado en cinco archivos), Mapbox (en cuatro sitios, aunque también existe config('app.map_box_token')), un bearer de la propia plataforma en una librería, y un token estático de 20 caracteres en un middleware comparado con !=.l-home-restaurantes: HomeController.php:270-273 · Console/Commands/Billing/RucPeru.php:56,89 · Libraries/Api/V3/DniLibrary.php:22 · Libraries/Api/V3/RSMLibrary.php:65,399 · Http/Middleware/LlaveClienteToken.php:11
// Middleware/LlaveClienteToken.php:11
if ($request->bearerToken() != 'xxxxxxxxxxxxxxxxxxxx') { ... }
Por qué duele
Cinco copias del mismo token son cinco lugares que olvidar al rotarlo. Comparar secretos con != permite ataques de tiempo. Y el token de "la propia API" en el código es un backdoor con nombre de librería.
Así se hace
// una sola clave por credencial
$token = config('services.apisnet.token');
// comparar secretos siempre en tiempo constante
if (! hash_equals(config('services.cliente.key'), (string) $request->bearerToken())) {
abort(401);
}
Regla. Una credencial = una clave de config; nunca un literal, nunca duplicada, nunca comparada con ==.
3 · El secreto viaja por la URL
alta[exp] [pll]
Así está
// Controller.php:61 [exp] · ReservationController.php:1078 [pll]
$client->get("auth/1234?type=U&token={$defaultToken}");
El master token se manda como parámetro de query en un GET, en cada page view, para acuñar un token de comensal.
Por qué duele
Todo lo que va en la URL queda en los logs de acceso del balanceador, del backend, de los proxies, y en cualquier herramienta de observabilidad. Es el secreto más importante del sistema escrito en texto plano en cien lugares.
Así se hace
$client->post('auth/token', [
'headers' => ['Authorization' => 'Bearer '.config('services.mesa.robot_token')],
'json' => ['type' => 'U'],
]);
Regla. Credenciales en headers o cuerpo, nunca en la URL; y con POST, no GET.
4 · La zona horaria como constante del código (y "restar cinco horas")
alta[home] [exp] [pll]
Así está
// config/app.php:254 [home] · :68 [exp]
'timezone' => 'UTC',
// AnalyticsController.php:187 [home]
gmdate('Y-m-d', time() - 3660*5)
// 10 sitios más: ->subHours(5) / ->addHours(5)
[home] además 102 literales 'America/Lima' en 50 archivos y 570 Carbon::now() sin argumento. [pll] el síntoma se parchó en el cliente con una fecha en duro: start_date: "2026-07-01" "porque el server en UTC ya está en agosto".l-home-restaurantes: config/app.php:254 · Http/Helpers/date.php:7-9 · l-parallevar: detailv2.blade.php:895,2693 · l-widgetexperiencias: Libraries/Utilities.php:39-41
Por qué duele
Cloud Run corre en UTC. Desde las 19:00 de Lima, "hoy" es mañana: reservas que caen en otro día, reportes corridos, disponibilidad equivocada. Ya nos pasó y lo documentamos. Restar 5 horas ignora que Chile tiene horario de verano y que ya somos cuatro países.
Así se hace
// config/app.php
'timezone' => env('APP_TIMEZONE', 'America/Lima'),
// la zona es un dato del local, no del servidor
$ahora = Carbon::now($local->timezone); // 'America/Santiago', 'America/Guayaquil'…
$hoy = Carbon::today($local->timezone);
// nunca: date(), strtotime(), time()-3600*5, new Date() en JS para "hoy"
Regla. Toda fecha nace con la zona del local (o del tenant) explícita; nunca se "convierte" sumando horas ni se parcha con una fecha literal.
5 · La configuración se resuelve dentro del request
media[exp]
Así está
public/index.php comprueba en cada request si existe .env; si no, hace curl al metadata server, descarga el archivo desde GCS y ejecuta exec('php ../artisan config:clear') (y cache/route clear). Con concurrencia 30, varios requests escriben el mismo archivo a la vez.l-widgetexperiencias: public/index.php:12-57 · Dockerfile:37-38 (sin config:cache)
Por qué duele
El primer request de cada instancia fría paga una descarga y tres procesos; si GCS falla, la app arranca sin config y reintenta en cada request; y hay un exec() en el front controller. La misma familia de problemas apareció en web202101 (APP_DEBUG=true y LOG_LEVEL=critical viviendo en un .env en un bucket).
Así se hace
Config por variables de entorno y --set-secrets de Cloud Run; si hay que descargar algo, en docker-entrypoint.sh antes de arrancar PHP y fallando rápido; php artisan config:cache y route:cache en el build.
Regla. La configuración se resuelve al arrancar el proceso, nunca en el request; y en producción está cacheada.
B · Lo que le damos al navegador
6 · El token de sesión impreso en HTML, devuelto en JSON y guardado en localStorage
alta[pll] [exp] [libro]
Así está
{{-- detailv2.blade.php:2724-2725 [pll] --}}
access_token: '{{ $access_token }}',
access_token2: "{{ $access_token2 }}", // el de STAFF
{{-- index.blade.php:712-713 [exp] --}}
localStorage.setItem('expToken', "{{ $accessToken }}");
[pll] 25 vistas con access_token, 17 "Bearer " + "{{ $access_token }}" solo en el carrito, y /login, /registro, /login-social devuelven el token en JSON. [exp] 16 localStorage.setItem('expToken') y 37 llamadas axios directas a la API con ese bearer. [libro] el token en localStorage.token y embebido en hrefs hacia /configuration/#/<TOKEN>/….l-parallevar: resources/views/reservation/detailv2.blade.php:2724 · togo/cart.blade.php ×17 · TogoController.php:367-375 · l-widgetexperiencias: LoginController.php:172-179 · l-librodereservas: src/Utils/Initialize.js:62 · Components/Navigation/Navigation.jsx:519
Por qué duele
Es el vicio que causó el incidente. Cualquier curl a una página pública devolvía un bearer de staff en texto plano; y con un token de comensal se podía obtener uno de staff (escalada confirmada en producción). Los tokens duraban 365 días y matar el endpoint no los revocaba. Lo que el navegador ve, cualquiera lo ve: en el HTML, en un console.log, en el localStorage de una PC compartida del restaurante, o robado por un script inyectado (ver vicio 11).
Así se hace
// El navegador habla con NUESTRO servidor (misma sesión, CSRF).
Route::post('/reservas/{slug}/disponibilidad', [DisponibilidadController::class, 'show']);
// El servidor habla con la API con el token que solo él conoce.
public function show(string $slug, DisponibilidadRequest $req, MesaApi $api) {
return $api->disponibilidad($slug, $req->validated()); // bearer desde session()/config
}
Si de verdad el navegador debe llamar a la API: cookie HttpOnly; Secure; SameSite emitida por la API, o un token de corta vida y alcance mínimo. Nunca el token de sesión completo, nunca en localStorage, nunca en una URL.
Regla. Ningún token de sesión ni credencial de servidor se serializa a HTML, JSON de respuesta, localStorage ni URL. El navegador habla con nuestro servidor; nuestro servidor habla con la API.
7 · Credenciales de la pasarela en el HTML y firmas sobre el monto que manda el cliente
alta[pll] [exp]
Así está
{{-- cart.blade.php:1545-1546 [pll] · cart/detail.blade.php:35-36 [exp] --}}
apiKey: "{{ $payu_api_key }}", // la misma clave con la que el servidor FIRMA
apiLogin: "{{ $payu_api_login }}"
// TogoController.php:700-712 [pll] · ExperienceController.php:364-367 [exp]
$signature = md5($ApiKey."~".$merchant."~".$ref."~".$request->amount."~".$request->currency);
Y /checkout reenvía $request->transaction (con PAN y CVV) tal cual a la pasarela, devolviendo la excepción como JSON si falla. Es código de un flujo que el front ya no usa, pero sigue ruteado en producción.
Por qué duele
Quien puede pedir una firma para el monto que quiera puede pagar 1 sol una orden de 500 si la pasarela solo valida la firma. La API key de comercio en el HTML de todo carrito es un secreto regalado. Y pasar tarjetas por nuestro servidor nos mete en alcance PCI.
Así se hace
public function signature(SignatureRequest $req, CartRepository $carts) {
$cart = $carts->forOrder($req->order_id); // el monto lo decide el servidor
return ['signature' => PayU::sign($cart->total, $cart->currency, $cart->reference)];
}
// las credenciales de pasarela no se pasan a la vista; el flujo muerto se borra (rutas + controlador + props)
Regla. El servidor es la única fuente de verdad del monto a cobrar; el cliente solo dice "quiero pagar el carrito X". Y "es demo/UAT" no es excusa: si está ruteado en prod, es prod.
8 · URLs crudas de Cloud Run llamadas desde el navegador (y desde el entorno de desarrollo)
alta[pll] [libro]
Así está
// detailv2.blade.php:4801-4803 [pll]
if (self.useNewReservationCalendarService == 1) {
baseRoute = 'https://m-b-core-api-staging-…run.app/v2/search'; // ¡staging! desde el widget en prod
// src/Config/Origins.js:25-37 [libro]
const DEV_ORIGIN = { apiUrl: 'https://mesa-backend-prod-….run.app/v1', … } // localhost → PRODUCCIÓN
Por qué duele
Una URL absoluta se salta el balanceador, Cloud Armor y el ip-enforcer que pusimos justamente para el incidente (la segunda ola de agosto entró por la URL cruda de run.app). Locales en producción consultando disponibilidad contra staging. Y cada npm start en la laptop de un dev pega contra la base de datos de producción con un token real.
Así se hace
// Laravel: config → vista con @json, o mejor, ruta same-origin
'calendar_url' => env('CALENDAR_SERVICE_URL'),
// React: por variable de build, dev apunta a staging
const API_URL = process.env.REACT_APP_API_URL; // .env.development → staging detrás del LB
Regla. Ninguna URL de servicio se escribe en una vista ni en el bundle; entra por config/entorno, siempre por el dominio detrás del balanceador, y el default de desarrollo nunca es producción.
C · Entrada, salida y errores
9 · Nadie valida la entrada; el request va directo al modelo
alta[home] [exp] [pll]
Así está
// AgentController.php:44 [home]
new RepresentanteLegal( $request->except('_token') );
// PreviewController.php:43 [home]
$local->fill( $request->all() );
// app/UsuarioSistema.php:39 [home]
protected $fillable = [ …, 'Password', 'PasswordPlain', 'Salt' ];
[home] 163 de 208 controladores que reciben Request no validan nada; 17 modelos con $guarded = []. [exp] una sola llamada a validate() en todo app/, y es scaffolding. [pll] cero validaciones en rutas vivas frente a 56 $request->campo.
Por qué duele
Campos que nadie esperaba llegan a la base de datos (incluida la contraseña de un usuario de sistema por asignación masiva). El error, cuando ocurre, lo produce MySQL como 500 en vez de un 422 claro. Y un input malformado que se reenvía a una pasarela es un 500 opaco lejos de la causa.
Así se hace
class StoreAgenteRequest extends FormRequest {
public function rules(): array {
return ['nombre' => 'required|string|max:120', 'documento' => 'required|digits:8'];
}
}
public function store(StoreAgenteRequest $req) {
$agente = RepresentanteLegal::create($req->validated()); // solo lo validado
return response()->json($agente, 201);
}
// $fillable mínimo y explícito; jamás Password/Salt dentro
Ya hay 58 FormRequests bien hechas en [home] (RequestCapacity es un buen ejemplo). La técnica existe; falta hacerla obligatoria.
Regla. Toda acción pública empieza validando: sin FormRequest no hay controlador; nada del request toca un modelo sin pasar por validated().
10 · SQL con variables interpoladas
alta[home]
Así está
// app/ProgramacionReserva.php:31
->whereRaw("( ...HoraInicio <= '$hour' AND ...HoraFin >= '$hour' )")
// Libraries/SearchV2.php:1891
->whereRaw("(Fecha = '{$date_start}' OR Dia = '{$day_of_week}')")
// LocalController.php:218
DB::select("select * from ProgramacionReservasNuevo PR where PR.LocalId = {$local->Id} ...")
377 whereRaw, 430 DB::select, 297 DB::raw; 92 whereRaw con variables dentro del string en 45 archivos. El patrón con input del usuario ({$request->term}) existió y quedó comentado.
Por qué duele
Hoy muchas de esas variables son internas. El hábito no distingue origen: el día que una venga del request es inyección SQL sobre la base que tiene las contraseñas. Y el revisor no puede saber, en un diff, si $hour vino del usuario.
Así se hace
->whereRaw('HoraInicio <= ? AND HoraFin >= ?', [$hour, $hour])
DB::select('select * from ProgramacionReservasNuevo where LocalId = ?', [$local->Id])
// y para casi todo: el query builder, que parametriza solo
Programacion::where('LocalId', $local->Id)->whereTime('HoraInicio', '<=', $hour)
En el mismo repo ya está bien hecho en ChargebackController.php:114: bindings. Esa es la vara.
Regla. Ningún valor entra a un string SQL por interpolación o concatenación; siempre placeholder o query builder.
11 · HTML sin escapar: {!! !!}, v-html, innerHTML
alta[exp] [pll] [libro]
Así está
{{-- layouts/app.blade.php:70-173 [exp] --}}
{!! $data['local']['extras']['head_scripts'] !!}
{!! $data['local']['extras']['body_scripts'] !!}
{{-- payment.blade.php:1058 [pll] --}}
{!! $widget['header_scripts'] !!}
// Api.js:89 [libro]
containerError.innerHTML = '<div class="network-error">' + message + …
[pll] 135 v-html en el widget de reservas. [exp] el HTML/JS/CSS del tenant inyectado en el layout de la página donde vive el token y el formulario de tarjeta.
Por qué duele
El skimmer de agosto entró por dynamic_inputs: contenido del panel de restaurantes (cuyas cuentas comparten contraseña) renderizado sin escapar en 90 locales. Cada v-html nuevo es la misma puerta.
Así se hace
{{ }} y v-text por defecto. Si el negocio exige HTML del tenant: sanitizar en servidor con lista blanca (HTMLPurifier), nada de <script> inline (GTM por ID + lista de dominios), y CSP con nonce. En React: JSX y textContent, nunca strings concatenados.
Regla. El default es escapado. {!! !!} / v-html / innerHTML requieren justificación en el PR y sanitizado explícito con lista blanca.
12 · El error se traga, se imprime, o se devuelve como 200
alta[home] [pll] [exp]
Así está
// AgentController.php:56-58 [home] (en un store() web)
catch( Exception $e ){ echo $e->getMessage(); }
// ExperienceController.php:353-356 [exp]
catch(RequestException $e){ $data = $e; } return response()->json($data); // 200 con la excepción serializada
// TogoController.php:361-366 [pll] — la API cayó, pero al usuario:
return ['success' => false, 'message' => 'Tu email y/o contraseña son incorrectas']; // HTTP 200
// Exceptions/Handler.php:92 [home] — en PRODUCCIÓN:
$message = $exception->getFile()." - ".$exception->getLine()." - ".$exception->getMessage();
[home] 876 catch: 73 hacen echo, 71 devuelven success:false con 200, 28 están vacíos; el Handler además guarda request()->all() (con contraseñas y tarjetas si vienen) en la tabla de logs. [pll] 40 catch(RequestException) y un solo Log:: en toda la app; helpers que devuelven $e->getMessage() como si fuera un array y luego un isset($token['acces_token']) sobre un string. [exp] getAccessToken(): string que en el catch hace return redirect(): TypeError.
Por qué duele
La app móvil y el widget no pueden distinguir éxito de fallo por status. Los errores tragados no llegan a Sentry ni a Cloud Logging: cuando el core falló el 24 de julio, la operación no vio nada. El usuario recibe culpas falsas ("contraseña incorrecta" cuando la API está caída). Y las rutas de archivo y trazas en la respuesta son un mapa para el atacante.
Así se hace
// Excepciones del dominio, mapeadas una sola vez en Handler::render
class UpstreamNoDisponible extends \RuntimeException {}
try {
$r = $api->login($req->validated());
} catch (RequestException $e) {
report($e); // Sentry/log con contexto
throw new UpstreamNoDisponible('core', 0, $e);
}
// Handler::render → UpstreamNoDisponible ⇒ 503 {"message":"Servicio no disponible","id":"req-…"}
// De negocio: response()->json(['message'=>'Sin stock'], 409). Nunca 200 con error.
// En producción el cliente recibe mensaje + id de correlación; el detalle va al log.
Regla. Un catch reporta y relanza (o traduce a un tipo de error), o no existe. Nunca echo, nunca devuelve $e, nunca 200 para un error, nunca inventa el mensaje.
13 · Llamadas HTTP salientes sin timeout, un cliente nuevo por método
alta[pll] [exp] [libro] [home]
Así está
[pll] 41 new \GuzzleHttp\Client, cero con timeout; una página de reserva hace cuatro llamadas en serie al core con max_execution_time=60 y 30 workers. [exp] 16 clientes, igual. [libro] axios sin timeout. [home] 21 curl_init a mano conviviendo con Guzzle, y 8 sitios con verify => false (sin verificar TLS) para pagos y consulta de DNI.l-parallevar: TogoController.php:189, ReservationController.php:625,672,850,1076 · l-home-restaurantes: Console/Commands/Billing/RucPeru.php:57,90 · Traits/PushNotification.php:162
Por qué duele
Un upstream lento (el 24 de julio, el 401 de TokenLogeo) retiene cada worker hasta 60 segundos; con 30 workers y concurrencia 30 la instancia se satura y Cloud Run escala pagando. Sin connect_timeout, un host caído cuelga el connect. Y verify=false es MITM sobre pagos.
Así se hace
// app/Services/CoreApiClient.php — singleton registrado en el container
$this->http = new Client([
'base_uri' => config('services.core.url'),
'timeout' => 5,
'connect_timeout' => 2,
'handler' => $stackConRetryParaGET, // reintentos solo en idempotentes
]);
// JS: axios.create({ baseURL, timeout: 15000 }) una vez, exportado
Regla. Toda llamada de red tiene timeout corto, se hace por un cliente único inyectado, verifica TLS, y su base URL sale de config (no de un str_replace('v1','v3')).
14 · Un GET que crea cosas
media[pll]
Así está
TogoController::index() (el GET de la home del restaurante) crea una cuenta real xxx@mesa247.info en el core si no viene ?guest_user, hay un solo local y el user-agent "parece navegador". Sin try/catch (el único método sin él).l-parallevar: TogoController.php:250-260, 1298-1330
Por qué duele
Cada crawler o prefetch que pase el filtro crea una identidad en la tabla Usuario (el "lote fantasma" del 24 de julio). Y un fallo del core en ese POST tumba la home con 500. También [home]: 52 rutas de primer nivel sin auth:api, incluidas GET /v1/test con dump() y POST /v1/payment/refund.
Así se hace
La cuenta invitada se crea cuando el usuario actúa (POST del carrito o de la reserva), con manejo de error y degradación a modo anónimo. Las rutas públicas son un grupo pequeño y explícito con throttle; todo lo demás dentro de auth; los scripts de mantenimiento son comandos Artisan, no rutas.
Regla. Un GET no crea ni muta estado; y la ruta pública es la excepción declarada, no el default.
D · Estructura
15 · Controladores, métodos y componentes gigantes
alta[home] [pll] [libro] [exp]
Así está
[home] AnalyticsController 4.391 líneas con un show() de ~2.900; ReservationController::store() ~970 líneas con 40 if a 20 espacios de indentación; 245 métodos de más de 150 líneas; un trait de 3.533 líneas. [pll] TogoController 1.359 líneas, sin app/Services. [libro] ReservationInfo.jsx 6.427 líneas, un render() de 3.485, 74 claves de state, 194 setState. [exp] vistas de 2.209 líneas con 1.492 de JS.l-home-restaurantes: Admin/Clients/Analytics/AnalyticsController.php · Api/Book/Reservation/ReservationController.php:806-1775 · l-librodereservas: Components/…/ReservationInfo.jsx
Por qué duele
No se puede revisar en un PR, no se puede testear, y cada bugfix toca el mismo archivo que todos los demás: conflictos y regresiones. La lógica de "reservar" está en el mismo método que la autenticación, el SEO y los redirects.
Así se hace
// El controlador orquesta; una clase por caso de uso; métodos que caben en una pantalla
public function store(StoreReservaRequest $req, CrearReserva $crear) {
$reserva = $crear->ejecutar(ReservaData::fromRequest($req)); // app/Services o app/Actions
return new ReservaResource($reserva); // 201
}
// React: contenedor (datos) + presentación; subcomponentes por pestaña < 300 líneas; reglas en módulos puros con test
Regla. Si un método no cabe en una pantalla es más de un método; si un controlador pasa de 300 líneas (o un componente de 500) es más de uno. La lógica de negocio vive en una capa de servicio testeable.
16 · Copiar, pegar, cambiar diez líneas
alta[home] [pll] [exp] [libro]
Así está
[pll] getLocalInfo() pegada en cinco controladores (diff entre dos de ellas: una línea), checkRestaurantSubdomain() ×5, el bloque SEO de 11 líneas ×11. [home] 3.987 bloques duplicados; PedidosYaLibrary ↔ PedidosYaLibraryV3, ContractController ↔ ContractOldController, tres ReservationErrorController; archivos versionados por sufijo (Anunciante2, SearchV2, editOld.blade). [exp] el fallback de local ×5, el switch($host) de SEO ×3, componentes gemelos CartDetailComponent / …IziPay / …Old. [libro] WaitListItem vs WalkinListItem: 354 de 419 líneas idénticas.
Por qué duele
Cada fix (el rawurlencode, el X-Auth-Sguard, el sufijo .ec/.co) hay que aplicarlo N veces y alguna se olvida: experiencias.mesa247.chile (host inexistente) sobrevivió en cuatro sitios. Y nadie sabe cuál de las copias está viva: en [pll] detail.blade.php (7.679 líneas) no se renderiza desde hace tiempo y se sigue parcheando en paralelo a detailv2.
Así se hace
A la segunda copia se extrae a un servicio o componente parametrizado; a la tercera ya es deuda. Las versiones "V2/Old/Nuevo" se hacen con ramas o feature flags y la vieja se borra al terminar. Un archivo muerto se borra en el mismo PR que lo deja obsoleto: git guarda la historia.
Regla. Copiar-pegar-modificar es deuda instantánea: extraer antes de duplicar; y nunca sufijos 2/Old/Nuevo en producción.
17 · La lógica (y aplicaciones enteras) dentro de la vista
alta[pll] [exp] [home]
Así está
[pll] 21.008 líneas de JavaScript inline en 47 Blade; 27 new Vue( dentro de vistas; el bundle compilado por mix tiene 31 líneas. Estado de negocio como literales en la plantilla: start_date: "2026-07-01", [4612, 4477].includes(consumption_type) //HACK. [exp] 54 config() en vistas, bucles anidados en @php, funciones JS generadas por interpolación de Blade. [home] queries dentro de Blade: \DB::table('FacturaReporte')… en invoicing/reports/index.blade.php:120-145.
Por qué duele
Ese JS no pasa por linter, minificación, tests ni cache; cada cambio de UI es un diff en un archivo de 8.000 líneas; los hotfix de negocio quedan como literales que caducan. Mezclar Blade con JS crea bugs de escape ("{{ $token }}" dentro de un string JS es exactamente el vicio 6).
Así se hace
{{-- Blade solo maqueta e inyecta datos por un único puente --}}
<script type="application/json" id="app-props">@json($props)</script>
<script src="{{ mix('js/reservas.js') }}"></script>
// resources/assets/js/reservas/*.vue → compilado, linteado, testeable
// Un ViewModel/ViewComposer entrega el array; la vista no llama a config(), session() ni DB
Regla. La vista no consulta y no calcula: pinta datos ya preparados; el JS vive en resources/assets/js y pasa por el build.
18 · Reglas de negocio como números mágicos: IDs de locales, correos, umbrales
media[home] [pll] [exp] [libro]
Así está
// ReservationController.php:823 [home]
$freeReservationEnabled = (($request->email == 'persona@…' && in_array($local_id, [2346, 2432, 2387, 2433])) || …);
// CreditCardController.php:120 [home] if(in_array($user->Id, [40700, 48496]))
// detailv2.blade.php:2315 [pll] ![93,2192,686,54,27,175,55,28,2363].includes(local_id) // "cusco restaurants asked"
// ReservationListItem.jsx:1262 [libro] onClick={localStorage.userType > 8 ? … } // ×65
[home] 90 in_array($local, [ids]), 135 LocalId == N, autorización por sufijo @mesa247.* en 37 archivos frente a 3 Policies. [pll] 59 arrays de IDs. [libro] autorización decidida en el cliente con userType > 8 en 65 sitios.
Por qué duele
Son reglas de negocio invisibles para el negocio: nadie sabe por qué existen ni cuándo caducan; sobreviven al cliente que las motivó; activar algo para un restaurante exige un deploy; y con cuatro países los IDs colisionan. Un umbral de rol en el cliente lo cambia cualquiera en DevTools.
Así se hace
Flags por local en la tabla de configuración del local (que la API ya devuelve) o en config/business.php con nombre y comentario. Roles y permisos por Gate/Policy en el servidor; la UI solo oculta con can('editar_reserva') derivado de lo que el backend dice.
Regla. Un ID o un correo en el código es una regla de negocio escondida: sácala a datos con nombre. La UI oculta; el servidor autoriza.
19 · Estado global a mano y consultas dentro del bucle
media[libro] [home]
Así está
[libro] un singleton Config mutable instanciado 16 veces, 48 claves distintas de localStorage (localId leído 164 veces), un bus fbemitter con 233 emit de strings libres y 37 listeners de los que solo 15 se dan de baja; 30 mutaciones directas de this.state; 175 new XxxApi() que registran un handler jQuery cada uno. [home] 94 foreach con ::find/->where dentro (N+1) aunque ->with() se usa 273 veces.
Por qué duele
Dos fuentes de verdad que se resincronizan a mano, fugas de memoria al desmontar, flujo de datos que nadie puede seguir. En el backend: cientos de queries por request en listados de reservas, sobre instancias con poca CPU.
Así se hace
Un store con acciones nombradas (Context/Redux/Zustand), localStorage solo como caché de arranque leído en un único módulo, listeners siempre con su remove(). En Laravel: with(), whereIn + keyBy, o chunk; contar queries en desarrollo.
Regla. Una sola fuente de verdad por dato; una query por colección, no por elemento.
E · Proceso, herramientas e higiene
20 · Merge = deploy, sin tests, sin lint, sin nadie que pueda decir que no
alta[home] [pll] [exp] [libro]
Así está
Los tres cloudbuild.yaml tienen tres pasos: build, push, deploy. Ningún phpunit, php -l, composer validate, análisis estático ni aprobación. Tests: 21 en [home] (2 son ExampleTest), 2 de scaffolding en [pll] y [exp], 1 en [libro] más dos runners caseros "porque el jest de react-scripts está roto". [libro] compila con CI=false DISABLE_ESLINT_PLUGIN=true, y cómo llega build/ a producción no está en el repo. Ningún phpstan, php-cs-fixer, pint ni .eslintrc efectivo. Frameworks y runtimes fuera de soporte: Laravel 5.4/5.5, PHP 7.3/7.4, Node 12/14, React 15, Vue 2.
Por qué duele
Cualquier dd(), die() o error de sintaxis llega a producción. No hay red de seguridad para refactorizar los gigantes del vicio 15. Ningún CVE de Laravel o PHP desde 2020 se puede parchear con un composer update. Y el PR es la única barrera, así que cuando el PR no se revisa, no hay barrera.
Así se hace
# cloudbuild.yaml — antes de construir la imagen
- name: composer:2 args: ['install', '--no-interaction']
- name: php:7.4-cli entrypoint: vendor/bin/phpunit
- name: php:7.4-cli entrypoint: vendor/bin/phpstan args: ['analyse', '--level=1']
# recién después: build → push → deploy (a staging; prod desde tag o con aprobación manual)
Rama protegida con una revisión obligatoria y CI verde. Plan de upgrade por saltos con la suite de tests como red (ver plan).
Regla. Nada llega a producción sin que una máquina lo haya ejecutado antes; el trigger de deploy no dispara en merge sin gate.
21 · Lo que no debería estar en el repo: builds, basura, dependencias, y lo que sí debería: el lock
media[home] [pll] [exp] [libro]
Así está
[home] vendor/ commiteado y composer.lock en .gitignore (builds no reproducibles, .git de 294 MB). [pll] [exp] public/js/app.js y public/css/app.css compilados y versionados (822 KB; nada garantiza que coincidan con la fuente), web.config de IIS en un contenedor Apache, tres copias de v-calendar. [libro] logs del profiler V8, tree.txt, updates.txt, un README genérico de 104 KB, y un build que modifica public/index.html en cada ejecución. [exp] txt.txt vacío, appOld.css, componentes *Old.vue.
Por qué duele
Un git blame sobre un CSS compilado no dice nada; los PR de estilos son conflictos binarios; sin lock, dos builds del mismo commit instalan versiones distintas; y una imagen con vendor/ de desarrollo, dumps y llaves es más grande y más peligrosa.
Así se hace
Se versiona la fuente y el lock; los artefactos se generan en el build (etapa Node + composer install --no-dev en un Dockerfile multi-stage); .gitignore y .dockerignore reales; la versión se inyecta como variable de build, no editando archivos versionados.
Regla. Se versiona el lock, no las dependencias; se versiona la fuente, no el resultado del build; el build produce artefactos y nunca modifica el árbol.
22 · Depuración, código muerto y "feat: up"
media[home] [pll] [exp] [libro]
Así está
[home] dd( en 61 archivos (13 vivas, una en un controlador web), 900 líneas con dump(, 37 exit/die vivos, 240 echo, $_REQUEST directo, ~7.600 líneas de código comentado (con credenciales de eFact dentro de un comentario en config/services.php:38-70). [libro] 77 console.log activos en 27 archivos, uno con los datos de la reserva; 838 líneas comentadas; un archivo TableMap/html sin extensión que nadie importa; métodos getReservationDates y getReservationDates2. [exp] 42 console.log. Mensajes de commit históricos: feat: up ×9, fix var, Update app.php, hardcode code, set hard key; mediana de 18 caracteres.
Por qué duele
die() rompe el ciclo de respuesta (sin logs, sin middleware) y en un comando convierte un error en exit 0. El código comentado no dice si volverá, y los secretos comentados siguen siendo secretos. git blame con "feat: up" no responde "por qué está esto así", que es justo lo que se necesita con tantos IDs mágicos.
Así se hace
Hook de pre-commit y regla de lint que fallan con dd(/dump(/console.log. Borrar el código comentado en el mismo PR que lo desactiva. Commits tipo(ámbito): qué — por qué, como ya hacen los de agosto (fix(logs): chequear existencia columna por columna, no via centinela): esa es la vara.
Regla. dd/dump/echo/die/console.log no se mergean (el linter lo impide); el código comentado se borra; el commit explica la intención, no repite el diff.
23 · Contraseñas: MD5+salt todavía se crean, y PasswordPlain sigue en el modelo
alta[home]
Así está
// Auth/RegisterController.php:157,165
$salt = substr(md5(time()), 0, 10); … 'Password' => md5($password . $salt)
// Console/Commands/User/CreateFromContact.php:122-123
$user->PasswordPlain = $password; $user->Password = md5($password . $salt);
Por qué duele
Hay 1,6 millones de contraseñas en claro en Usuario.PasswordPlain y 7.484 cuentas del panel que comparten una. Mientras exista un camino que escriba MD5 o plain, la migración nunca termina. Lo positivo: app/Support/PasswordHasher.php ya verifica el legado con hash_equals y rehashea a bcrypt; y el login pone PasswordPlain = null.
Así se hace
Hash::make() (bcrypt/argon) en toda alta, por una única puerta (PasswordHasher); PasswordPlain y Salt fuera del $fillable y, cuando el reset esté hecho, fuera del esquema.
Regla. Hay una única función que escribe contraseñas, y usa bcrypt/argon; nada las guarda en claro, ni "temporalmente".
F · Infraestructura, operación y datos (segunda ronda)
Segunda ronda del 16 de agosto: siete analistas (cuatro con Fable, tres con Opus) sobre m-incidente, m-b-core, los repos de operación (ip-enforcer, detector-anomalias, l-network, m-infrastructure) y una segunda pasada con lentes nuevas sobre los cuatro repos de la primera ronda. Los hallazgos donde los dos modelos coincidieron de forma independiente están marcados Fable+Opus. Detalle y evidencia completa en el anexo INFORME-analisis-ampliado-2026-08-16.md.
24 · El proxy que reabre TLS no verifica el certificado del backend
alta[m-incidente/webs-vhosts] [l-network] · Fable+Opus
Así está
# webs-vhosts/templates/vanity.conf.tmpl y 74/74 build/*.conf · l-network: 78/92 vhosts
SSLProxyVerify none
SSLProxyCheckPeerName off
Y los scripts que comprueban el alta de dominios usan curl -sk (7 sitios). En la capa de aplicación es el mismo vicio: 8 sitios con verify => false en [home], incluidas llamadas a pasarelas.m-incidente: webs-vhosts/templates/vanity.conf.tmpl · aws-newmesa/onboard_dominios.sh · l-network: vm-web2026/etc/apache2/sites-enabled/*.conf
Por qué duele
El tramo AWS→GCP viaja por Internet. Sin verificar el peer, quien controle el camino (o el DNS de un tenant) puede suplantar al backend y servir el skimmer desde el proxy, para 74 dominios de restaurantes. El SNI ya es el tenant correcto: no hay motivo técnico para apagar la verificación.
Así se hace
SSLProxyVerify require
SSLProxyCheckPeerName on
SSLProxyCACertificateFile /etc/ssl/certs/ca-certificates.crt
Regla. verify=false no se mergea: ni en un cliente HTTP, ni en un .conf. Un proxy que reabre TLS verifica el upstream igual que un navegador.
25 · Vhosts sin cabeceras de seguridad, sin log propio y con el banner del sistema
alta[webs-vhosts] [l-network] · Fable+Opus
Así está
0/74 vhosts generados (y 0/92 en la VM) con Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options o X-Frame-Options; 0/74 con ErrorLog/CustomLog propio (todos caen en el log global, sin %v garantizado); ServerTokens OS y ServerSignature On en conf-enabled/security.conf; SSLProtocol/SSLCipherSuite dependen del apache2.conf del box, que no está versionado; php8.5 cargado en un gateway que solo proxea; 74 vhosts con RequestHeader unset Accept-Encoding para poder reescribir el HTML con Substitute (sin compresión para nadie).m-incidente: webs-vhosts/build/*.conf · l-network: vm-web2026/etc/apache2/conf-enabled/security.conf:12,23 · mods-available/ssl.conf:57
Por qué duele
El daño material del incidente fue un <script src> a un workers.dev inyectado en el checkout: una CSP con script-src en lista blanca neutraliza esa clase entera y es una línea en la plantilla que ya existe. Sin log por vhost, responder "¿este dominio fue atacado el día X?" es grepear un log mezclado de 74 sitios. El banner con versión de SO se lo damos a quien ya conoce esta flota.
Así se hace
Un bloque Header always set … (CSP primero en Report-Only, luego enforcing; HSTS; nosniff) en la plantilla, ErrorLog/CustomLog por vhost o LogFormat con %v a un colector, ServerTokens Prod, ServerSignature Off, cipher suite "intermediate" de Mozilla versionada, y validar.sh que falle si un vhost no las trae.
Regla. Lo que decide la seguridad del borde (TLS, cabeceras, logs) vive versionado junto al vhost y se valida en el build; tras un skimmer, CSP es control primario, no "hardening".
26 · Producción se edita a mano, y el deploy "declarativo" no lo es
alta[m-incidente/scripts] [l-network] · Fable+Opus
Así está
Cinco scripts hacen hotfix con sed -i directamente sobre /etc/apache2/sites-enabled como root (escritura no atómica; el rollback restaura los .bak de otras corridas); dos copias divergentes del script de alta de dominios (283 líneas de diff), y la vieja deduce el tenant del dominio, exactamente lo que el README de webs-vhosts prohíbe en negrita ("40 de 82 tienen slug distinto"). En l-network, el rsync a sites-enabled va sin --delete (un vhost borrado en git vive en la VM para siempre), el rollback no borra los archivos nuevos que rompieron el configtest, y /etc/hosts entero se reemplaza desde el repo (18 vhosts dependen de una línea ahí). El pipeline entra a la VM con ssh-keyscan … || true + StrictHostKeyChecking=no. Y el README promete desplegar.sh, auditar.sh y un runbook que no existen.m-incidente: 03-remediacion-y-migracion/scripts/fix_www_roto.sh:52-58 · aws-newmesa/onboard_dominios.sh:88 · l-network: deploy/l-network-setup-vm.sh:39-49 · .github/workflows/deploy.yml:29-41
Por qué duele
El estado del servidor deja de derivarse del repo: el siguiente deploy pisa el hotfix o el hotfix pisa el repo, y nadie sabe qué corre. Un rollback que no revierte es peor que no tener rollback, porque da confianza. Y el canal de deploy sin verificar la identidad del destino es un vector hacia el borde de producción, que además es la misma flota EC2 comprometida en junio.
Así se hace
El fix se hace en plantilla/datos → generador → validación → deploy del build/; si hubo que operar a mano, vuelve al repo el mismo día. rsync -a --delete en ambos sentidos (o apache2ctl -S + diff antes/después), rollback con rsync --delete desde el backup, /etc/hosts gestionado por línea (o DNS interno), known_hosts fijo en el repo con StrictHostKeyChecking=yes, llave de deploy con command= forzado. Una plantilla, un generador, un runbook; el README se verifica contra ls en el mismo PR.
Regla. En producción no se edita, se despliega; un deploy declarativo borra lo que ya no está en el repo y su rollback deja el sistema exactamente como antes; la automatización nunca desactiva la verificación de identidad del destino.
27 · Código de seguridad copiado en tres repos: el bypass que uno corrigió, el otro lo reintrodujo
alta[ip-enforcer] [l-network] [detector-anomalias]
Así está
// l-network/scripts/ip-enforcer/main.go:122-146 — recorre XFF de derecha a izquierda saltando proxies confiables
// "verified live on 2026-08-13: curl -H 'X-Forwarded-For: 8.8.8.8' from a banned IP bypassed the ban under the old first-entry logic"
// ip-enforcer/main.go:214-226 (repo nuevo, commit posterior) — vuelve al PRIMER elemento:
first := strings.TrimSpace(strings.SplitN(xff, ",", 2)[0])
Tres artefactos del enforcer (la imagen desplegada cuya fuente "se perdió", la copia en l-network, el repo nuevo) y ningún repo dice cuál corre en prod. Detector, poller y enforcer canonicalizan el doc-id de IP de tres formas distintas (net.ParseIP().String() vs string crudo): una IPv6 escrita de otra forma en XFF produce un ban que "silenciosamente nunca matchea". config.go y analyzer.go del detector difieren entre su repo y la copia de l-network.l-network/scripts/ban-poller/banwriter.go:14-25 · detector-anomalias/store.go:60-66 · ip-enforcer/main.go:230-238
Por qué duele
Si el repo nuevo llega a prod, cualquiera que hable directo con run.app evade el ban con un header, y puede hacer que se banee la IP que él elija (incluidos los GFE del balanceador: 403 para todo el servicio, porque esa versión no tiene never-ban de proxies). Es el vicio 16 (copiar-pegar) aplicado al control que sostiene la producción durante el incidente.
Así se hace
Una sola fuente por componente (las copias se borran o se referencian por submódulo/SHA de imagen); una función compartida banid usada por escritores y lector; test que reproduzca el bypass del 13 de agosto; y el README diciendo qué SHA corre en prod y cómo verificarlo (gcloud run services describe … --format='value(spec.template.spec.containers[].image)').
Regla. El control de ingress tiene una única fuente versionada, con test del bypass conocido; escritor y lector de una lista de bloqueo comparten el mismo código de clave, no la misma intención.
28 · Controles que fallan abiertos sin avisar, y configuración "preservada" en la consola
alta[ip-enforcer] [detector-anomalias]
Así está
El enforcer, si la lectura de Firestore devuelve 0 documentos, instala el snapshot vacío sin log ni métrica; si Firestore falla al arrancar, deja pasar todo ("start with empty lists (allow)"). Nadie mide count(banned_ips); /healthz devuelve 200 aunque Firestore esté caído (igual en detector y poller). El vaciado del 15 de agosto (1.449 → 0) se descubrió por sus efectos y ningún escritor versionado lo explica (solo hacen Create). El detector arranca en MODE=enforce, con TRUSTED_IP_PREFIXES y NEVER_BAN_IPS como env "preservadas del servicio existente" por el cloudbuild: el estado real vive en la consola. El script que desbanea (sin versionar) borra por batchWrite sin paginar, sin dry-run y con un backup posiblemente parcial.ip-enforcer/main.go:110-114,187-201 · detector-anomalias/cloudbuild.yaml:22-31 · config.go:225-232 · m-incidente/05-monitoreo/scripts/unban-browser-user-agents.py
Por qué duele
Fail-open es la decisión correcta para no tumbar el servicio; fail-open sin alarma es "sin protección y sin saberlo". Y si alguien recrea el servicio o redeploya con --set-env-vars incompletos, el detector vuelve a solo-avisar en silencio.
Así se hace
Métrica snapshot_size{coll} en cada refresh y alerta cuando cae >50 % o a 0; /healthz que reporte edad y tamaño del snapshot; job diario que compara banned_ips con el último backup; los env del servicio en el cloudbuild.yaml/Terraform con --set-env-vars explícito; quitar un control exige el mismo rigor que ponerlo: dry-run, backup verificado y registro.
Regla. Todo control que falla abierto emite una señal cuando lo hace, y alguien la mira; la configuración que decide si protege o solo avisa está versionada, no "preservada".
29 · La infraestructura nueva nace con los defaults que el incidente ya pagó
alta[m-infrastructure] [m-b-core/cloudbuild] · Fable+Opus
Así está
# m-infrastructure/modules/cloud_run/main.tf:7 · variables.tf:60-62
ingress = "INGRESS_TRAFFIC_ALL"
variable "allow_unauthenticated" { default = true } # → member = "allUsers"
# modules/cloud_sql/main.tf:9,36 deletion_protection = false · ipv4_enabled = true
# backend.tf:6-13 backend GCS comentado: "State: local by default (quick)"
# Makefile:39-56 apply-prod / destroy-prod con -auto-approve
Y en m-b-core: los tres cloudbuild.yaml solo añaden --add-cloudsql-instances "si el trigger define _LEGACY_SQL_INSTANCE; en prod no está definida" (comentario textual): como gcloud run deploy es declarativo, cada build arranca el conector de la nueva revisión (causa raíz del incidente del 16-ago); el .env completo baja de Secret Manager a un archivo del workspace, se hace head -5 a los logs y termina como variables de entorno planas de Cloud Run vía --env-vars-file (no --set-secrets); el comando de deploy se construye por concatenación y se evalúa; alembic/env.py:47 "enmascara" la URL con DB_URL.split('@')[0], que conserva justo usuario:contraseña, y lo imprime en Cloud Build en cada migración (verificado ejecutando la expresión).m-b-core: services/api/cloudbuild.yaml:125-128,152-153,286-298 · alembic/env.py:47-51 · m-infrastructure/scripts/load-secrets.sh:6,29 (set -a)
Por qué duele
Es reproducir en el proyecto nuevo exactamente lo que el incidente enseñó a no hacer: run.app público fuera del LB, base de datos con IP pública, destroy-prod a un make de distancia, state local que dos personas pueden pisar, y una contraseña de Postgres escrita en Cloud Logging en cada deploy.
Así se hace
ingress = INTERNAL_AND_GCLB y allow_unauthenticated = false como default con excepción explícita; deletion_protection = true en prod; state en GCS con locking desde el día 1; apply con plan revisado; _LEGACY_SQL_INSTANCE obligatoria en la validación de variables que ya existe (o gcloud run services replace service.yaml); --set-secrets por clave; make_url(DB_URL).render_as_string(hide_password=True); nunca head sobre un archivo de secretos; argumentos como array, no eval.
Regla. Con gcloud run deploy todo flag omitido se pierde: la configuración de la revisión está completa en el pipeline o en un YAML versionado; la infra nueva nace privada, detrás del LB, protegida contra borrado y con state compartido; una máscara de credenciales sin test es una fuga.
30 · El repo del incidente reproduce la fuga que investiga
alta[m-incidente] · Fable+Opus
Así está
Credenciales de producción completas pegadas como "evidencia" en informes de main (la contraseña de la BD y una secret key de AWS en REVISION-l-gateway.md:29; el MASTER_API_TOKEN entero en un informe del 12-ago; ≥15 valores en 6 informes según Opus), mientras el mismo dato aparece enmascarado en scans/l-gateway.md:76: dos políticas de redacción en la misma auditoría. Correos personales de comensales y empleados en 11 informes. El export de notificación de tarjetas selecciona TarjetaNumero y TarjetaHash (cuyo salt está commiteado en l-gateway) junto a nombre/email a un .xlsx sin cifrar, teniendo ya RIGHT(...,4). La cifra de tarjetas afectadas que va a la ANPD y a Izipay tiene seis valores conviviendo (989 / 569 / 636 / 811 / 494 / ~1.940) y el README publica la más vieja. 36 commits de forense en una rama local sin pushear y 16 archivos sin trackear; el código operativo mejor hecho (run-sql.py, el desbaneo, waf-fase-1.sh) está sin versionar; el reset de 222.792 contraseñas se ejecuta pegando 9 UPDATE en Cloud SQL Studio; 50/58 commits directos a main; una decisión de seguridad ("los tokens expiran solos en ~40 días", eran 365) tomada sobre un supuesto sin etiquetar.m-incidente: 02-revision-repos/REVISION-l-gateway.md:29 · 00-incidente/2026-08-12/INFORME-mapeo-completo-tokenlogeo.md:32 · 01-tarjetas-y-notificacion/export_tarjetas_afectadas.py:78-99 · README.md:20 · 03-remediacion-y-migracion/RESET-MASIVO.sql:4,11-12
Por qué duele
Cada clon, laptop y agente que lee el repo tiene la contraseña de la BD que contiene 1,9 M de contraseñas en claro. Un secreto "pendiente de rotación" en un doc de julio sigue vivo en agosto. La respuesta al incidente crea un activo nuevo de datos de tarjeta fuera de todo control. Y un tercero que entre por la puerta indicada ("leer primero") se lleva la cifra equivocada.
Así se hace
En documentación, un secreto se cita prefijo…sufijo siempre; el valor completo, si hace falta para IR, en el gestor de secretos referenciado por ruta; gitleaks en pre-commit también aquí. PII de terceros enmascarada, IOCs del atacante en su propia sección. Un export selecciona lo que el mensaje necesita (email, últimos 4, marca), a destino cifrado con caducidad, dueño y fecha de borrado escritos. Un NUMEROS-OFICIALES.md con valor, unidad, ventana, query y fecha; el resto lo enlaza. Commit y push al cierre de cada jornada; los supuestos etiquetados [SUPUESTO] como ya hace el informe maestro; las mutaciones masivas con un runner que deja registro (run-sql.py), nunca con el portapapeles.
Regla. El repo de incidente no es una excepción a las reglas de secretos y PII: es el peor lugar para un secreto. Un número que sale de la empresa tiene un solo dueño, una query y una fecha.
31 · El estado del pago lo dice el cliente, y nada es idempotente
alta[home] [pll] [exp] · segunda pasada
Así está
// routes/api.php:103 [home] — fuera del grupo auth:api
Route::post('ecommerce/orders/{order_id}/process-transaction', …);
// OrderController.php:2418-2422 (y :2628, :2724, :2916)
$transaction = request()->kr_answer != null ? json_decode(request()->kr_answer) : [];
if ($transaction->orderStatus == 'PAID') { … $payment->PagoEstado = 'Pagado'; $reservation->EstadoId = 1; }
Ninguna verificación de kr-hash con la clave HMAC de Izipay en todo [home] (contraste: [pll] tiene verifyKrHash bien hecho); no se lee el estado previo antes de escribir (el mismo IPN reenviado se procesa de nuevo). Los informes del incidente lo documentan como patrón: tres de las cuatro apps de pago aceptan callbacks sin firma; una calcula el HMAC y lo ignora; en otra la "hash key" es la palabra password; y /signature firma cualquier monto (vicio 7). Además tres tablas de log guardan request()->all() de pagos.
Por qué duele
Cualquiera puede POSTear un JSON con orderStatus:"PAID" y confirmar la reserva o el pedido sin pagar. Y un webhook reintentado por la pasarela duplica efectos.
Así se hace
Verificar la firma con hash_equals (o consultar el estado a la API de la pasarela server-to-server); idempotencia por transactionUuid único antes de escribir; el endpoint de IPN separado del que llama el navegador; el log de pago guarda identificadores y estados, no el payload.
Regla. El estado de un pago lo dice la pasarela verificada (firma o consulta), nunca el cuerpo que manda el cliente; todo webhook es idempotente por id de transacción.
32 · Escrituras sin transacción ni bloqueo, colas que no corren
alta[home] · segunda pasada
Así está
0 lockForUpdate/FOR UPDATE/GET_LOCK en todo app/. La asignación de mesa es update + create + save sin DB::transaction ni comprobación de que la mesa esté libre (check-then-insert sin lock). En ReservationController::store() el commit está en la línea 1469 pero el método sigue hasta la 1775 haciendo save/create/update fuera de la transacción. QUEUE_DRIVER=sync con 101 clases ShouldQueue; 9 $schedule->command() activos sin ningún schedule:run ni cron en Dockerfile/cloudbuild.l-home-restaurantes: Libraries/ReservationTable.php:34-56 · Api/Book/Reservation/ReservationController.php:1185,1469,1636-1736 · config/queue.php:18 · Console/Kernel.php:359-366
Por qué duele
Dos peticiones simultáneas asignan la misma mesa; un fallo a mitad deja ReservaMesa en estado 3 sin la fila nueva. Los mails y push se envían dentro del request (latencia y fallos acoplados al checkout) y las tareas "programadas" dependen de un cron que no existe en Cloud Run (los robots muertos del incidente).
Así se hace
DB::transaction(fn () => …) envolviendo toda la unidad (mesa + reserva + etiquetas), lockForUpdate() o restricción única (MesaId, Fecha, Hora) que haga imposible el duplicado; efectos externos después del commit; QUEUE_CONNECTION=redis|database con un worker real y Cloud Scheduler para el schedule, o quitar ShouldQueue si se decide síncrono.
Regla. Una unidad de negocio = una transacción; lo que debe ser único lo garantiza la BD, no un if; si algo se declara asíncrono o programado, existe el proceso que lo ejecuta y una alerta si deja de correr.
33 · El código sabe que existe Perú
media[home] [pll] [exp] [libro] · segunda pasada
Así está
[home]: S/ ×238, +51 ×51, DNI ×64, 'PEN' ×78, Pais == 'PE' ×91, mesa247.pe ×1.162, America/Lima ×115, 36 case 'PE'|'CL'|'EC'|'CO' dispersos, 43 throw new Exception('texto en español') sin i18n (pese a 1.433 usos de _i()). [pll] S/ ×114; [exp] filtros de precio en soles para todos los países; [libro] "+51 Perú" como default, moment.locale('es') ×19.l-home-restaurantes: Traits/Ecommerce/ToGo/Order.php:556 · Traits/Payment/Payment.php:1734 · Traits/Util.php:148 · l-librodereservas: Data.json:38
Por qué duele
La plataforma opera en cuatro países y es multitenant: cada literal es un bug de país o un deploy por cambiar un teléfono. Multipaís ya costó un PR por país en el widget.
Así se hace
Local/Pais como entidad con moneda, símbolo, teléfono de soporte, dominio y zona; formateo con NumberFormatter/Intl; todo texto visible por _i(); un linter que marque Exception(' con tilde o ñ.
Regla. País, moneda, zona, teléfono y dominio son datos del tenant; el código no sabe que existe Perú; un texto que ve un usuario nunca es un literal.
34 · Dependencias con CVE público, ramas dev en producción y SDKs copiados a mano
alta[home] [pll] [exp] [libro] · segunda pasada
Así está
Versiones exactas (gracias a vendor/ commiteado): laravel/framework 5.4.36 (CVE-2018-15133: RCE por deserialización de cookie si APP_KEY se filtra, y el .env está commiteado), guzzle 6.5.5/6.3.3 (CVE-2022-29248 y familia), symfony/http-* 3.4.x (sin soporte de seguridad desde 2021), lcobucci/jwt 3.3.0 (CVE-2021-41106, lo usa Passport), firebase/php-jwt 4.0.0, phpoffice/phpexcel 1.8.2 (abandonado, XXE), intervention/image "dev-main" (rama móvil en producción); whoops y tinker en require; SDK de PayU copiado a mano (516 KB) sin versión; 9 curl_init + 36 Guzzle. [libro]: axios con CVE-2023-45857 documentado en los informes.
Por qué duele
No son teóricos: deserialización de cookie con APP_KEY filtrada es RCE en el backend que ya fue comprometido. Un SDK copiado no recibe parches ni se sabe qué versión es.
Así se hace
composer audit / npm audit en CI, bloqueantes; composer.lock versionado y vendor/ fuera; ramas dev-* prohibidas; herramientas de desarrollo en require-dev; SDKs solo por gestor de dependencias; una librería HTTP por proyecto.
Regla. La CI falla con CVE conocido; nada dev-* en producción; código de terceros solo vía gestor de dependencias.
35 · Rendimiento que se paga en Cloud Run: sin paginación, sin cache, sin config:cache, sesión en disco
media[home] [pll] [exp] · segunda pasada
Así está
[home]: 251 ->get() vs 3 paginate() en Api/; 34 SELECT *; 102 LIKE '%…%' (y relaciones guardadas como ids separados por coma en una columna, 6 sitios); 0 Cache::remember; 1.692 rutas compiladas en cada request porque ningún Dockerfile hace config:cache/route:cache (los *:clear están comentados); 18 vistas con ?v={{ time() }}. [pll]/[exp]: sesión y cache file por defecto en un servicio con autoescalado y sin afinidad (causa documentada del 401 a media reserva).
Por qué duele
Respuestas y memoria crecen con la tabla; LIKE '%x%' no usa índice; el cache-buster por segundo desactiva el CDN; y con min-instances=10 al 14 % de CPU, cada request caro es dinero (la caída del 24-jul fue contención, no tráfico).
Así se hace
Paginación obligatoria en listados; columnas explícitas; tablas pivote en vez de CSV en columna; Cache::remember('clave:v1', ttl, fn) con Redis; config:cache + route:cache en el Dockerfile (ya se cumple env() solo en config/); assets versionados por hash (mix()); SESSION_DRIVER=redis|database.
Regla. Ningún endpoint devuelve una colección sin límite; la imagen se construye con config y rutas cacheadas; ningún estado entre requests vive en el disco del contenedor.
36 · API sin convención, imagen sin dueño, README sin contenido
media[home] [pll] [exp] [libro] · segunda pasada
Así está
Rutas snake_case ×161 vs kebab ×37 vs camelCase ×5; 10 GET que mutan (mark-ipn, send_emails, assign); el typo experiencies en 21 rutas ya es contrato público; v1/v2/v3 reimplementados por copia; 0 API Resources (203 'success' => TRUE a mano); Usuario/UsuarioSistema con $hidden que no incluye PasswordPlain ni Salt (hoy salvado por select() explícito en 5 sitios; 36 find/first a una línea del leak). Docker: COPY . ./, npm install --force ×2 en la imagen PHP, chmod -R 775, 0 USER/HEALTHCHECK en cuatro repos, el sidecar de seguridad desplegado con :latest en cada build del backend, --allow-unauthenticated sin --ingress, min/max/ingress fuera del repo. README de [home] = 1 línea; 33 docblocks "Display a listing of the resource."; 85 TODO|HACK con nombre de cliente y sin ticket. [libro]: 76 onClick en div/span, 14 <img> sin alt, 296 .map vs 177 key, 0 expiración de token en cliente.
Por qué duele
Los clientes acoplan a nombres con typo y a formas de respuesta distintas por endpoint; el modelo sin $hidden correcto es un leak a una línea; un cambio del enforcer se cuela en un deploy del backend sin revisión; y cada ingeniero (o cada IA) reconstruye el contexto desde cero.
Así se hace
Convención escrita (kebab en URL, snake en JSON), Resources como contrato, verbos HTTP correctos, $hidden con todo lo sensible; imagen multi-stage, no-root, por digest, con la config del servicio versionada (Terraform o service.yaml); README con qué es / cómo correr / variables / cómo desplegar; cada HACK con ticket y caducidad; en el frontend, <button>, alt, key estable y expiración en el interceptor.
Regla. La API tiene una convención escrita y una clase de respuesta por recurso; ninguna propiedad del servicio de producción se configura solo en la consola; un repo sin README de "cómo correr" no está listo para otro humano.
G · m-b-core frente a su propio estándar
m-b-core es, con diferencia, el mejor repo del ecosistema (estándar escrito, 4.931 tests, gates de cobertura, CI real, secretos desde Secret Manager en el pipeline, comentarios honestos). Justamente por eso importa lo que no cumple: son los vicios que van a definir la próxima década si entran ahora.
37 · Endpoints con PII sin autenticación "porque el gateway autentica arriba"
alta[m-b-core] · Opus (verificado)
Así está
# services/api/app/libro/auth_compat.py:3-8
The libro endpoints are migrated **without** auth validation (see plan, rule "sin migración de auth").
… read the `user_id` claim from the JWT … **without verifying its signature**.
The gateway upstream is responsible for actually authenticating.
Los 8 endpoints de libro/routes.py (/v1/locals/{local_id}/reservations, /diners/search, …) devuelven nombre, email, teléfono y alergias de comensales sin Depends de auth ni comprobación de tenencia; el router se monta sin dependencies=. POST /v1/profiling/summary recibe un reservation_id arbitrario, arma un dossier conductual del comensal y lo manda a OpenAI (con LangSmith trazando), sin auth: IDOR + fuga de PII a un tercero + coste sin límite. El propio prompt del sistema pide "no incluir datos personales" mientras el builder inyecta email y nombre.m-b-core: services/api/app/libro/routes.py:105 · router.py:29-30 · profiling_engine/routes.py:42 · summarizer.py:74,204
Por qué duele
Es la lógica que ya falló en la escalada bookAuth de agosto: delegar la autorización a un componente que no está en el mismo repo ni verificado en el despliegue, mientras el propio Cloud Run es alcanzable directamente. Alergias es dato de salud.
Así se hace
APIRouter(dependencies=[Depends(get_current_user)]) en el router de libro + assert_can_access_local(user, local_id) resolviendo los locales del UsuarioSistema desde la BD, no desde el token; si el gateway es de verdad el único ingreso, demostrarlo con --ingress=internal-and-cloud-load-balancing e IAM, no con un comentario; en profiling, auth + pertenencia y no enviar PII al LLM.
Regla. Un endpoint que devuelve PII no delega su autorización a algo que no está en el mismo repo ni verificado en el despliegue; todo endpoint que recibe un id de negocio como única entrada necesita, además de auth, comprobación de pertenencia.
38 · La nueva casa repite los vicios viejos: .env versionados, admin key estática, "UTC menos 5"
alta[m-b-core] · Fable+Opus
Así está
env/local.env y env/staging.env versionados con valores reales (URL de la MySQL legacy de prod sobre IP pública, DB_URL con password, JWT_SECRET, OPENAI_API_KEY, SEND_API_KEY…), 20 commits de historial, y el .gitignore los desexcluye a propósito (!/env/local.env), aunque el pipeline ya lee secretos de Secret Manager. 65 endpoints admin en staging (incluido un ejecutor SQL sobre Postgres y MySQL legacy, proxy HTTP, exports, BigQuery) detrás de una api-key estática compartida, comparada con != en 23 sitios (0 compare_digest), con el guard copiado 19 veces; la API de prod publica /docs y /openapi.json (worker y bridge sí los apagan). engine.compute() con now = datetime.now() naive por defecto y una ruta que lo llama sin now; libro/reservation_detail.py:891: datetime.utcnow() + timedelta(hours=-5) ("approximate by shifting UTC to Lima"), el vicio 4 literal; date.today() a 170 líneas del comentario que explica por qué no usarlo; EstadoId = 1 incrustado 153 veces en SQL. MD5 legacy verificado con == y sin rehash (el legacy ya rehashea).m-b-core: env/local.env · .gitignore:211-215 · services/api/app/admin/_deps.py:13-21 · main.py:37-45 · availability_engine/engine.py:601 · listings/routes.py:1032,1083 · libro/reservation_detail.py:891 · auth/service.py:70-84
Por qué duele
Un atacante ya enumeró /openapi.json y /v1/admin/* el 16 de agosto: con el mapa público y una clave compartida que además está en git, no hay identidad, revocación individual ni auditoría de quién ejecutó qué SQL. El bug de las 19:00 vuelve por la puerta de atrás en el motor que se supone lo corrige.
Así se hace
Solo *.env.template en git y rotar todo lo expuesto; identidad por persona para /v1/admin/* (IAP / IAM / OIDC) o claves por operador con hash y auditoría; hmac.compare_digest; una sola dependencia de auth en shared/; /docs solo en staging; compute(now) obligatorio o default now_local(config); ruff DTZ en select; constante ESTADO_ACTIVO; una única verificación de credenciales en tiempo constante que migra a bcrypt en cada login.
Regla. Si el .gitignore necesita una excepción para dejar entrar un .env, el diseño está mal; la superficie admin se autentica por persona, no por secreto compartido; ningún "ahora" sin la zona del restaurante, y el linter lo impide.
39 · Gates que cubren un módulo y una rama de producción sin protección
alta[m-b-core] · Fable+Opus
Así está
origin/prod (8-may) y origin/staging (12-ago) divergen 1.072 commits; CONTRIBUTING.md admite "branch protection no enforced… GitHub permitirá saltárselas, no lo hagas"; make deploy-force hace git commit --allow-empty -m "force deploy [skip ci]" && git push origin prod: un target del repo que salta la CI a propósito; 273 ramas remotas de agentes y experimentos. En CI, mypy cubre 1 de 32 módulos de la API (nada de bridge, worker, shared); los tests de bridge y worker no corren en CI; pytest.ini ignora globalmente DeprecationWarning (el utcnow() deprecado nunca avisa); el gate de cobertura exime justo los archivos más grandes y su sustituto es un workbench manual contra producción; sin lock de transitivas. Además: 118 os.getenv fuera de settings.py (que documenta un rate limiter "in-memory" que es Redis y no usa el pydantic-settings instalado); archivos de 3.044, 2.612 y 1.618 líneas con una ruta de 469 (el controlador de 4.000 líneas naciendo de nuevo); 41 except Exception (6 solo pass) y 30 de 62 logs con f-string en vez de extra={}, casi todo en ai/; subprocess.run(pdftoppm) dentro de un async def del worker; Dockerfiles sin USER.m-b-core: CONTRIBUTING.md:5-12 · Makefile:205-217 · .github/workflows/ci.yml:38 · pytest.ini filterwarnings · shared/settings.py:12-32 · libro/reservation_detail.py · availability_engine/routes.py:1082 · worker/app/modules/menus/router.py:105,206
Por qué duele
Es "merge = deploy sin gate" con otro traje: la protección depende de la buena voluntad, deploy-force es el atajo que las reglas prohíben, y con 1.072 commits de divergencia una promoción staging→prod es un big-bang irrevisable. Un tipo mal en 3.044 líneas no lo ve nadie; el warning que avisaría está silenciado.
Así se hace
Proteger prod/staging con la herramienta (1 review + CI verde + sin push directo; pagar el plan si hace falta); borrar deploy-force (redeploy = trigger manual sobre el mismo SHA); promoción staging→prod frecuente y pequeña; mypy incremental por módulo (uno por mes); filterwarnings por warning concreto con motivo; tests de bridge/worker en CI; pip-compile/uv lock; BaseSettings; partir flexible_search y reservation_detail; except Exception solo con noqa: BLE001 — motivo y log; ruff BLE+G+DTZ+S; asyncio.to_thread; USER app.
Regla. La rama de producción está protegida por la herramienta, no por el README; nunca hay un comando en el repo que salte la CI; el gate crece con el repo y los warnings se silencian uno a uno con motivo.
H · web202101, gateway y apisocial (los que faltaban)
Auditados por Opus en esta ronda (48 hallazgos; muchos confirman los 23 vicios en otro repo). Aquí solo lo nuevo. Verificación puntual mía en los marcados; el resto queda como hallazgo del analista con su evidencia.
40 · Claves privadas y datos de comensales versionados dentro del DocumentRoot
alta[l-gateway] · Opus
Así está
Seis certificados APNs con la clave privada sin cifrar (application/notifications/*.pem, incluidos los de producción) y 74 CSV con 7.306 filas de reservas de comensales (nombre, teléfono, email) en application/csv/, versionados; el DocumentRoot es la raíz del repo y el único Deny del .htaccess cubre el literal .env (por eso .env.production se sirvió con 200 durante el incidente). Además index.php:207-214 escribe el cuerpo crudo de cada request (tokens y PII) a un archivo dentro del docroot, sin rotación. Y en origin/master_mesa, la rama que llega a prod, ExceptionHook.php:21 sigue con dd($msg): el fix existe solo en una rama sin mergear.l-gateway: application/notifications/apns-*.pem · application/csv/*.csv · .htaccess:6-9 · index.php:207-214 · application/hooks/ExceptionHook.php:21 (master_mesa)
Por qué duele
Apache sirve .pem, .csv y .txt como estáticos: el gate PHP no cubre archivos. Aunque no se haya comprobado por HTTP, los .pem están en el historial y hay que rotarlos igual. Y un fix de seguridad que no está en la rama de despliegue no es un fix.
Así se hace
DocumentRoot en public/; certificados en Secret Manager y rotados; CSV borrados y tratados como incidente de datos personales; el hook mergeado a la rama de deploy; el servidor niega dotfiles y extensiones no servibles por patrón, no por literal.
Regla. Nada que no sea servible públicamente vive debajo del DocumentRoot; los certificados no se versionan nunca; un fix de seguridad que no está en la rama de despliegue no existe.
41 · SQL interpolado justo donde más duele: en la autorización y en el manejador de errores
alta[l-gateway] · Opus
Así está
// application/core/MY_Controller.php:187-195 — autorización de usuarios externos
->where("(Local.Id = '{$localId}' OR Local.Alias = '{$slugLocal}')") // desde $this->input->post()
// application/hooks/ExceptionHook.php:58-59 — el logger de excepciones
$sql = "INSERT INTO LogSistema (Url, …, Data, …) VALUES ('{$url}', …, '{$data}', …)"; // $url y $data sin escapar
Además: token de servicio md5($id . time()) en columna compartida y canjeado por URL; 25 usos de md5 como aleatoriedad; 229 dump() en 32 archivos; Cache-Control: public, max-age=300, s-maxage=900 en todas las respuestas JSON del gateway, incluidas las autenticadas (api_helper.php:209-212); 22 date_default_timezone_set a mitad de request; credenciales de la BD de prod como fallback en database.php; CodeIgniter 2.1.4 (2012), 7.537 de 12.220 commits "no message".
Por qué duele
Una inyección en la propia comprobación de "¿este usuario puede tocar este local?"; y basta provocar una excepción cualquiera para tener ejecución SQL en el logger, con Host/REQUEST_URI/$_POST controlados por el atacante. Un CDN intermedio puede guardar la respuesta autenticada de un comensal y servírsela a otro.
Así se hace
Bindings de CI en ambos sitios; Cache-Control: private, no-store por defecto y cacheo explícito solo en catálogos; tokens con random_bytes, TTL y un solo uso; zona horaria explícita por operación, nunca cambiando el estado global del proceso.
Regla. El código de seguridad y el de manejo de errores son los que más cuidado necesitan, no los que se escriben rápido; el default de una API autenticada es no-store.
42 · Login sin throttle, backdoor de debug y observabilidad comentada en el sitio público
alta[l-web202101] · Opus
Así está
// routes/web.php:426-428 — sin throttle; el único limitador apunta a routes/api.php, que está vacío
Route::post('login/loginEP', 'UserController@login');
// RestaurantController.php:98-102
if (isset($debug) && $debug == 1) { dd($local); } // ?debug=1 en la ficha pública
Cero rate limiting en 239 rutas web (login, registro, recuperar contraseña); Sentry como dependencia con todas las capturas comentadas (Handler.php:51-55); GlobalApply registrado dos veces (2 llamadas HTTP y 40 View::share por pasada) con un die() ante sys_getloadavg() >= 60; 39 clientes Guzzle sin timeout y 15 catch que convierten el fallo del upstream en 404; tres modelos con namespace roto que dejan caídas api/countries|cities|phonecodes sin que nadie lo note; rand(0,1) fabricando "1000 puntos" y likes; date('Y-m-'.'30') ×5; token constante de fallback; 184 MB de binarios versionados; 328 commits "no message"; un hook pre-commit que reescribe una vista en cada commit; vistas de 4.500 líneas.
Por qué duele
Con 1,6 M de contraseñas en claro en la BD legacy, un login sin throttle es el frente natural de credential stuffing. Un ?debug=1 público vuelca la respuesta del API interno. Y sin Sentry activo, los errores del sitio más visible no los ve nadie.
Así se hace
->middleware('throttle:10,1') en la definición de cada ruta de credenciales (no en un grupo que quizá no se use) + limitador por email+IP; borrar el debug; descomentar Sentry o quitar la dependencia; un middleware una vez; timeouts.
Regla. Todo endpoint que valide credenciales lleva throttle explícito en su definición; ningún parámetro de query cambia el modo de depuración de una respuesta pública; observabilidad comentada es observabilidad ausente.
43 · Un token que no caduca, viaja en la URL y a veces lo elige el cliente
alta[l-apisocial] · Opus
Así está
// routes/web.php:14,18 — GET, sin auth, sin throttle: devuelven nombre, email, foto, IP, socialId
Route::get('facebook/info', …); Route::get('google/info', …); // ?soctoken=… (sin caducidad, reutilizable, en la query)
// Controller.php:69-75 — en el flujo popup, el "secreto" lo manda el cliente
return !empty($clientToken) ? $clientToken : Str::random(40);
// y el postMessage usa targetOrigin '*' bajo un comentario que dice lo contrario
.env.example con secretos reales de Facebook y Google; allowedOrigins: ['*']; la rama por defecto (pre_prod) no es la desplegada: Dockerfile y cloudbuild.yaml solo existen en master_mesa_docker_prod, 4 commits por delante con cambios funcionales.
Por qué duele
Un atacante elige el valor, induce a la víctima a completar el login y consulta /google/info?soctoken=<valor>. El token queda en historial, Referer y logs del balanceador. Y quien clona el repo por defecto no sabe cómo se despliega.
Así se hace
Token de un solo uso con TTL corto, generado siempre en servidor, canjeado por POST en el cuerpo y borrado al canjearlo; entregado solo por postMessage con targetOrigin explícito; la rama por defecto es la que se despliega.
Regla. El servidor nunca acepta como secreto un valor que eligió el cliente; un identificador que da acceso a datos personales caduca, se usa una vez y no viaja en la URL; la rama por defecto del repo es la que se despliega.
Cómo leer todo esto sin desanimarse
Son 43 vicios en 13 repos. Ninguno lo inventó una persona; son el resultado de años de "que salga hoy" sin un lugar donde estuviera escrito el criterio. La mayoría se resuelven con tres herramientas que no cuestan nada (validación al borde, un cliente HTTP único, un Handler de excepciones) y una regla de proceso (nada sin PR ni CI). Y en los mismos repos ya está la forma correcta de casi todo: verifyKrHash, las 58 FormRequests, PasswordHasher, Origins.js, la serie PLL-*. La guía solo pide que lo bueno deje de ser la excepción.
Trabajar con Claude en estos repos
La IA escribe rápido y bien; también repite con confianza los vicios que ya están en el repo, porque aprende del contexto que le damos. Las reglas de arriba aplican igual al código generado, y se agregan estas:
- Un
CLAUDE.md por repo con las reglas de esta guía.Así la IA parte con el criterio del equipo, no con el promedio de internet. Plantilla abajo.
- Pedidos del tamaño de un commit.
"Agrega validación de fecha en ReservaRequest con su test" sí. "Arregla el módulo de reservas" no.
- Lee el diff completo antes de aceptar.
Si es demasiado para leer, el pedido era demasiado grande.
- Test primero; que la IA implemente.
Tú describes el comportamiento con un test; la IA lo hace pasar. Y nunca le permitas cambiar el test para que pase.
- Ninguna dependencia nueva sin preguntar por qué.
Cada composer require / npm install es una decisión del equipo.
- Nada de secretos ni datos de clientes en el prompt.
Ni el .env, ni un dump, ni un token "para que entienda el formato". Se anonimiza o no se pega.
- Cuando falle, primero tú.
Lee el traceback, formula una hipótesis, y recién pregunta con la hipótesis. Iterar el mismo error 20 veces sin entenderlo no es debugging.
CLAUDE.md (plantilla para cada repo)# <nombre del repo>
Laravel 5.5 / PHP 7.x (o React 15). Repo de producción de Mesa247.
Regla base: código que no pueda explicar el autor no se mergea.
## Reglas para ti
- Propón el cambio más pequeño que resuelva el pedido; explica cada línea.
- Nunca pongas secretos, tokens ni URLs de producción en código, vistas,
JS, .env.example ni ejemplos. Config solo desde config/*.php con env().
- Nunca imprimas tokens en Blade/JS ni los guardes en localStorage.
- Errores: código HTTP correcto + log con contexto. Nada de catch vacío
ni de 200 con {"error"}.
- Fechas siempre con zona explícita (Carbon::now(config('app.timezone_pais'))
o la zona del local); nunca now()/date() a secas.
- SQL solo parametrizado o vía Eloquent/Query Builder.
- Lógica de negocio en app/Services (o Actions), no en controladores/vistas.
- Todo cambio de comportamiento trae test en tests/. No modifiques tests
para que pasen; si un test está mal, dilo y espera confirmación.
- No agregues dependencias sin preguntar.
- Antes de decir "listo": vendor/bin/phpunit y el linter deben pasar.
- Responde en español.
Así noHaz que funcione el checkout, me sale error 500.
Sin diagnóstico, sin límite, va a parchar el síntoma (y probablemente con un catch vacío).
Así síEl 500 sale en CheckoutController@store línea 142 cuando Redis no responde: el throttle usa cache. Propón cómo hacer que la caída de cache no tumbe la ruta, sin quitar el throttle, y explica el trade-off.
Diagnóstico tuyo, restricción explícita, decisión tuya.
Así noAgrega un endpoint que devuelva el token del usuario para usarlo desde el JS.
Le estás pidiendo que reproduzca el vicio que causó el incidente.
Así síEl JS de la vista necesita listar reservas del usuario logueado. Diseña un endpoint same-origin autenticado por sesión que llame a la API con el token del lado del servidor. El token no debe llegar al navegador.
Restricción de seguridad dicha antes, no descubierta después.