Arquitectura del flujo de peticiones
Cada request pasa por el middleware global antes de llegar a los route handlers individuales
Request
Cliente / Bot
→
middleware.ts
Shield + Bot + Token
→
Route Handler
/api/*
→
/api/auth/login — slidingWindow 5/15min
/api/leads/subscribe — validateEmail
/api/plans/[id] — tokenBucket 100/h
Global — shield() + detectBot()
middleware.ts — Protección global de la aplicación
Shield + bot detection + token bucket para todos los endpoints
nutriplan-saas/middleware.ts
TypeScript
NEW
import arcjet, { detectBot, shield, tokenBucket } from "@arcjet/next";
import { NextRequest, NextResponse } from "next/server";
// ─── Instancia global de Arcjet para NutriPlan ───────────────────────────────
const aj = arcjet({
key: process.env.ARCJET_KEY!,
characteristics: ["ip.src"],
rules: [
// Neutraliza SQLi, XSS y path traversal antes de que lleguen a la app
shield({ mode: "LIVE" }),
// Bloquea scrapers y headless browsers; permite bots legítimos
detectBot({
mode: "LIVE",
allow: [
"CATEGORY:SEARCH_ENGINE", // Googlebot, Bingbot
"CATEGORY:MONITOR", // Uptime Robot
"CATEGORY:PREVIEW", // Slack/Twitter unfurl
],
}),
// Rate limit global: 120 req/min con burst de 30 extra
tokenBucket({
mode: "LIVE",
refillRate: 60, // 60 tokens/min
interval: 60, // reposición cada 60 s
capacity: 120, // burst máximo
}),
],
});
export async function middleware(request: NextRequest) {
const decision = await aj.protect(request);
// Logging de todas las decisiones para el dashboard de Arcjet
console.log(`[Arcjet] ${request.url} | ${decision.conclusion} | IP: ${decision.ip.ip}`);
if (decision.isDenied()) {
if (decision.reason.isRateLimit()) {
return NextResponse.json(
{ error: "Demasiadas peticiones. Inténtalo en unos segundos." },
{ status: 429, headers: { "Retry-After": "60" } }
);
}
return NextResponse.json(
{ error: "Acceso denegado." },
{ status: 403 }
);
}
const response = NextResponse.next();
response.headers.set("X-Arcjet-Decision", decision.conclusion);
return response;
}
export const config = {
matcher: ["/api/:path*", "/((?!_next|favicon|_vercel).+)"],
};
Route handlers — protección por endpoint
Reglas específicas añadidas sobre el middleware global
app/api/auth/route.ts
TS
MOD
import arcjet, { slidingWindow } from "@arcjet/next";
// Login: 5 intentos / 15 min por IP
// Mitiga credential stuffing y brute force
const aj = arcjet({
key: process.env.ARCJET_KEY!,
characteristics: ["ip.src"],
rules: [
slidingWindow({
mode: "DRY_RUN", // Semana 1: log only
max: 5,
interval: "15m",
}),
],
});
export async function POST(req: NextRequest) {
const decision = await aj.protect(req);
if (decision.isDenied()) {
return NextResponse.json(
{ error: "Demasiados intentos" },
{ status: 429 }
);
}
// ... authenticate user
}
app/api/leads/route.ts
TS
MOD
import arcjet, { validateEmail } from "@arcjet/next";
// Rechaza desechables, inválidos y sin MX
// Protege la reputación de Resend/deliverability
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
validateEmail({
mode: "LIVE",
block: [
"DISPOSABLE",
"INVALID",
"NO_MX_RECORDS",
],
}),
],
});
export async function POST(req: NextRequest) {
const { email } = await req.json();
const decision = await aj.protect(req, { email });
if (decision.isDenied()) {
const r = decision.reason;
if (r.isEmail() &&
r.emailTypes.includes("DISPOSABLE")) {
return NextResponse.json(
{ error: "Usa un email permanente" },
{ status: 422 }
);
}
}
// ... add to Resend list
}
app/api/plans/[id]/route.ts
— API pública de planes nutricionales (anti-scraping)
TypeScript
MOD
import arcjet, { tokenBucket, detectBot } from "@arcjet/next";
// API pública: tolerante a bursts para integraciones, bloquea scrapers masivos
const aj = arcjet({
key: process.env.ARCJET_KEY!,
// Rate limit por API key, no por IP (clientes B2B pueden estar en el mismo NAT)
characteristics: ["http.request.headers[\"x-api-key\"]"],
rules: [
detectBot({ mode: "LIVE", allow: ["CATEGORY:MONITOR"] }),
tokenBucket({
mode: "LIVE",
refillRate: 100, // 100 req/h por API key
interval: 3600, // cada hora
capacity: 150, // burst extra para integraciones
}),
],
});
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
const decision = await aj.protect(req);
if (decision.isDenied()) {
const status = decision.reason.isRateLimit() ? 429 : 403;
return NextResponse.json(
{
error: decision.reason.isRateLimit()
? "API rate limit exceeded. Upgrade your plan."
: "Automated access not permitted.",
docs: "https://nutriplan.app/docs/api-limits",
},
{ status }
);
}
const plan = await getPlanById(params.id);
return NextResponse.json(plan);
}
Resumen de reglas configuradas
Visión consolidada de todos los puntos de aplicación de seguridad
| Endpoint | Regla | Configuración | Motivo | Modo |
|---|---|---|---|---|
| ALL /* | 🛡 Shield | SQLi · XSS · path traversal | Ataques de inyección globales | LIVE |
| ALL /* | 🤖 detectBot | allow: SEARCH_ENGINE, MONITOR, PREVIEW | Scrapers y headless browsers | LIVE |
| ALL /* | ⚡ tokenBucket | 60 req/min · burst 120 | Flood global desde una IP | LIVE |
| POST /api/auth/login | ⚡ slidingWindow | max: 5 · interval: 15m · by IP | Brute force · credential stuffing | DRY_RUN |
| POST /api/leads/subscribe | ✉ validateEmail | block: DISPOSABLE, INVALID, NO_MX | Deliverability · listas sucias | LIVE |
| GET /api/plans/[id] | 🤖 detectBot | allow: MONITOR only | Scraping del catálogo B2B | LIVE |
| GET /api/plans/[id] | ⚡ tokenBucket | 100 req/h · burst 150 · by API key | Límite por cliente B2B | LIVE |
Plan de despliegue en 4 fases
DRY_RUN → observación → ajuste de umbrales → LIVE
1
Semana 1 — Observación
Despliegue completo en DRY_RUN
Instalar
@arcjet/next, añadir ARCJET_KEY a Vercel. Todo en modo LOG: ningún usuario bloqueado, todos los patrones registrados en el dashboard.2
Semana 2 — Análisis
Revisar logs y calibrar umbrales
Analizar el tráfico real en el dashboard de Arcjet. Ajustar
refillRate y max basándose en percentiles de uso legítimo. Confirmar que Uptime Robot aparece como ALLOW.3
Semana 3 — Activación parcial
LIVE en shield + detectBot + validateEmail
Activar primero las reglas sin falsos positivos esperados. Mantener slidingWindow en DRY_RUN hasta confirmar que el login de usuarios reales no genera alertas de rate limit.
4
Semana 4 — LIVE completo
Todos los endpoints en modo LIVE
Cambiar
mode: "DRY_RUN" → mode: "LIVE" en el handler de login. Configurar alertas en el dashboard de Arcjet para picos de bloqueos anómalos. Stack de seguridad completamente activo.