feat: auditoría de seguridad en APIs, deuda Next 16 y pnpm como gestor
Cierra autenticación en /api/deployer, POST /api/modules, /api/stats y /verify (assertIT + validación de slugs/nombres). GET /api/modules público sanitizado (solo visibles, sin repoUrl ni logs). Corrige estado unhealthy, XSS en plantillas, open redirect en login. Sesión HMAC en tiempo constante, secret obligatorio, 503 si el backend cae, cookie secure solo en prod. Migra middleware a proxy, rate-limit en login, vitest + typecheck, Tailwind/CDN fuera, Boxicons local, polling pausado y código muerto eliminado. Estandariza pnpm (lock, Dockerfile, packageManager).
This commit is contained in:
1 parent
ba71c4daa6
commit
8f30b1c1ec
27 files changed
+5708
-7328
No files matched your search
@@ -4,17 +4,54 @@ import { COOKIE, isAllowed, signSession, SESSION_MAX_AGE } from "@/lib/auth";
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL || "https://api.devops.norteamericano.cl";
|
||||
|
||||
// Rate-limit en memoria: 10 intentos por IP cada 60s.
|
||||
// Suficiente para una sola instancia (standalone); con más
|
||||
// réplicas usar un store compartido (Redis/Upstash).
|
||||
const WINDOW_MS = 60_000;
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const attempts = new Map<string, { count: number; reset: number }>();
|
||||
|
||||
function clientIp(req: NextRequest): string {
|
||||
const fwd = req.headers.get("x-forwarded-for");
|
||||
if (fwd) return fwd.split(",")[0].trim();
|
||||
return req.headers.get("x-real-ip") || "unknown";
|
||||
}
|
||||
|
||||
function rateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const cur = attempts.get(ip);
|
||||
if (!cur || cur.reset <= now) {
|
||||
attempts.set(ip, { count: 1, reset: now + WINDOW_MS });
|
||||
return false;
|
||||
}
|
||||
cur.count += 1;
|
||||
// Limpieza oportunista para no crecer sin cota.
|
||||
if (attempts.size > 5000) {
|
||||
for (const [k, v] of attempts) {
|
||||
if (v.reset <= now) attempts.delete(k);
|
||||
}
|
||||
}
|
||||
return cur.count > MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
// Login IT: USUARIO = idUsuario. Valida en el backend
|
||||
// (sige_sam_v3.usuario, plano o SHA256) y exige que el email
|
||||
// retornado esté en IT_USERS (allowlist).
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
if (rateLimited(clientIp(req))) {
|
||||
return NextResponse.json(
|
||||
{ error: "Demasiados intentos, espera un minuto" },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
const { idUsuario, email, password } = await req.json();
|
||||
const id = String(idUsuario ?? email ?? "").trim();
|
||||
if (!id || !password) {
|
||||
return NextResponse.json({ error: "Faltan credenciales" }, { status: 400 });
|
||||
}
|
||||
let mail = "";
|
||||
let backendDown = false;
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/Auth/it-login`, {
|
||||
method: "POST",
|
||||
@@ -22,20 +59,40 @@ export async function POST(req: NextRequest) {
|
||||
body: JSON.stringify({ idUsuario: id, email: id, password: String(password) }),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error("backend");
|
||||
const j = await res.json();
|
||||
mail = String(j?.user?.email || "").trim().toLowerCase();
|
||||
} catch {
|
||||
mail = "";
|
||||
if (res.status >= 500) {
|
||||
backendDown = true;
|
||||
} else if (!res.ok) {
|
||||
mail = "";
|
||||
} else {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
mail = String(j?.user?.email || "").trim().toLowerCase();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[api/auth] backend it-login no disponible:", e);
|
||||
backendDown = true;
|
||||
}
|
||||
if (backendDown) {
|
||||
return NextResponse.json(
|
||||
{ error: "Servicio de autenticación no disponible, reintenta más tarde" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
if (!mail || !isAllowed(mail)) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
return NextResponse.json({ error: "Credenciales inválidas" }, { status: 401 });
|
||||
}
|
||||
let session: string;
|
||||
try {
|
||||
session = await signSession(mail);
|
||||
} catch (e) {
|
||||
console.error("[api/auth] IT_SESSION_SECRET inválido:", e);
|
||||
return NextResponse.json({ error: "Error de configuración del servidor" }, { status: 500 });
|
||||
}
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
const res = NextResponse.json({ ok: true, email: mail });
|
||||
res.cookies.set(COOKIE, await signSession(mail), {
|
||||
res.cookies.set(COOKIE, session, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
secure,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
@@ -47,7 +104,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(COOKIE, "", { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 0 });
|
||||
res.cookies.set(COOKIE, "", { httpOnly: true, secure, sameSite: "lax", path: "/", maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
Reference in new issue
Block a user