feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia
Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico, revocacion de JWT por version de sesion (028), control optimista de edicion (029), logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion de proyectos, accesibilidad de dialogos/controles e i18n. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,13 @@ import zlib from 'node:zlib';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { promisify } from 'node:util';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
import { env } from '../../config/env.js';
|
||||
import { HttpError, asyncHandler } from '../../utils/http.js';
|
||||
import * as repo from './repository.js';
|
||||
import { parseInventoryText, normalizeInventory } from './normalizer.js';
|
||||
import { redactUrl } from '../../utils/redactUrl.js';
|
||||
|
||||
// Ingesta del agente GLPI. NO usa JWT de usuario: cada instalacion lleva un token (guardado como
|
||||
// hash en inventory_tokens) que el agente manda como password Basic, Bearer, X-Ingest-Token o
|
||||
@@ -17,6 +20,8 @@ const MAX_INFLATED = MAX_BYTES * 4; // tope de inflado (anti zip-bomb)
|
||||
const REPORT_EVERY_HOURS = 24;
|
||||
|
||||
const sha256 = (v) => crypto.createHash('sha256').update(v).digest('hex');
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
const uncompress = promisify(zlib.inflate);
|
||||
|
||||
function extractToken(req) {
|
||||
if (req.headers['x-ingest-token']) return String(req.headers['x-ingest-token']).trim();
|
||||
@@ -32,10 +37,10 @@ function extractToken(req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function inflate(buf) {
|
||||
async function inflate(buf) {
|
||||
const opts = { maxOutputLength: MAX_INFLATED };
|
||||
if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return zlib.gunzipSync(buf, opts);
|
||||
if (buf.length > 2 && buf[0] === 0x78) return zlib.inflateSync(buf, opts);
|
||||
if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b) return gunzip(buf, opts);
|
||||
if (buf.length > 2 && buf[0] === 0x78) return uncompress(buf, opts);
|
||||
return buf;
|
||||
}
|
||||
|
||||
@@ -46,24 +51,43 @@ function detectFormat(text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveRaw(deviceid, text, format) {
|
||||
async function saveRaw(deviceid, text, format) {
|
||||
const dir = path.join(env.storageRoot, 'inventories');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
// Un archivo por equipo (se sobrescribe): solo interesa el ultimo reporte crudo para depurar.
|
||||
const name = `${sha256(deviceid).slice(0, 24)}.${format}`;
|
||||
fs.writeFileSync(path.join(dir, name), text);
|
||||
await fs.promises.writeFile(path.join(dir, name), text);
|
||||
return name;
|
||||
}
|
||||
|
||||
function parseInWorker(text, format) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(new URL('./parseWorker.js', import.meta.url), {
|
||||
workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 },
|
||||
});
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => worker.terminate(), 15_000);
|
||||
function finish(error, value) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (error) reject(error); else resolve(value);
|
||||
}
|
||||
worker.once('message', (value) => value.error ? finish(new Error(value.error)) : finish(null, value));
|
||||
worker.once('error', (error) => finish(error));
|
||||
worker.once('exit', (code) => { if (!settled) finish(new Error(`Parser interrumpido (${code})`)); });
|
||||
});
|
||||
}
|
||||
|
||||
const ok = (res, format) => (format === 'xml'
|
||||
? res.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><REPLY></REPLY>')
|
||||
: res.json({ status: 'ok' }));
|
||||
|
||||
const receive = asyncHandler(async (req, res) => {
|
||||
const token = extractToken(req);
|
||||
const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null;
|
||||
const tokenRow = req.inventoryToken;
|
||||
const token = req.inventoryTokenPresent;
|
||||
// Log de diagnostico de TODA peticion del agente (sin imprimir el token): util para depurar la conexion.
|
||||
console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} type=${req.headers['content-type'] || '-'} enc=${req.headers['content-encoding'] || '-'} bytes=${req.headers['content-length'] || '?'} token=${token ? (tokenRow ? 'valido' : 'INVALIDO') : 'ausente'}`);
|
||||
console.log(`[inventario] ${req.method} ${redactUrl(req.originalUrl)} desde ${req.ip} type=${req.headers['content-type'] || '-'} enc=${req.headers['content-encoding'] || '-'} bytes=${req.headers['content-length'] || '?'} token=${token ? (tokenRow ? 'valido' : 'INVALIDO') : 'ausente'}`);
|
||||
if (!tokenRow) {
|
||||
// El agente (LWP) manda las credenciales solo despues de recibir este desafio Basic.
|
||||
res.set('WWW-Authenticate', 'Basic realm="Project inventory"');
|
||||
@@ -76,7 +100,7 @@ const receive = asyncHandler(async (req, res) => {
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = inflate(body).toString('utf8');
|
||||
text = (await inflate(body)).toString('utf8');
|
||||
} catch {
|
||||
throw new HttpError(400, 'Cuerpo comprimido invalido o demasiado grande');
|
||||
}
|
||||
@@ -85,7 +109,7 @@ const receive = asyncHandler(async (req, res) => {
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseInventoryText(text, format);
|
||||
parsed = await parseInWorker(text, format);
|
||||
} catch {
|
||||
throw new HttpError(400, 'No se pudo interpretar el inventario');
|
||||
}
|
||||
@@ -102,8 +126,8 @@ const receive = asyncHandler(async (req, res) => {
|
||||
if (parsed.action && parsed.action !== 'inventory') return ok(res, format);
|
||||
|
||||
if (!parsed.deviceid) throw new HttpError(400, 'Inventario sin deviceid');
|
||||
const normalized = normalizeInventory(parsed);
|
||||
const rawFile = saveRaw(parsed.deviceid, text, format);
|
||||
const normalized = parsed.normalized;
|
||||
const rawFile = await saveRaw(parsed.deviceid, text, format);
|
||||
const result = await repo.upsertInventory(parsed.deviceid, normalized, rawFile);
|
||||
console.log(`[inventario] ${result.created ? 'nuevo' : 'actualizado'} #${result.id} ${normalized.device.hostname || parsed.deviceid} (${format}, ${result.changeCount} cambios)`);
|
||||
return ok(res, format);
|
||||
@@ -111,13 +135,32 @@ const receive = asyncHandler(async (req, res) => {
|
||||
|
||||
const router = express.Router();
|
||||
const rawBody = express.raw({ type: () => true, limit: MAX_BYTES });
|
||||
const limiter = rateLimit({ windowMs: 60_000, limit: 120, standardHeaders: 'draft-7', legacyHeaders: false });
|
||||
let activeRequests = 0;
|
||||
const preflight = asyncHandler(async (req, res, next) => {
|
||||
const token = extractToken(req);
|
||||
const tokenRow = token ? await repo.findActiveTokenByHash(sha256(token)) : null;
|
||||
req.inventoryTokenPresent = !!token;
|
||||
req.inventoryToken = tokenRow;
|
||||
if (!tokenRow) {
|
||||
res.set('WWW-Authenticate', 'Basic realm="Project inventory"');
|
||||
throw new HttpError(401, 'No autorizado');
|
||||
}
|
||||
if (activeRequests >= 2) throw new HttpError(503, 'Ingesta ocupada; reintenta');
|
||||
activeRequests++;
|
||||
let released = false;
|
||||
function release() { if (!released) { released = true; activeRequests--; } }
|
||||
res.once('finish', release);
|
||||
res.once('close', release);
|
||||
next();
|
||||
});
|
||||
|
||||
// Ruta propia + alias de la ruta nativa de GLPI (para no parchear el agente).
|
||||
router.post('/api/inventory', rawBody, receive);
|
||||
router.post('/front/inventory.php', rawBody, receive);
|
||||
router.post('/api/inventory', limiter, preflight, rawBody, receive);
|
||||
router.post('/front/inventory.php', limiter, preflight, rawBody, receive);
|
||||
// Cualquier otro metodo/ruta que el agente pruebe bajo estas rutas queda registrado.
|
||||
router.all(['/api/inventory', '/front/inventory.php'], (req, res) => {
|
||||
console.log(`[inventario] ${req.method} ${req.originalUrl} desde ${req.ip} (metodo no soportado)`);
|
||||
console.log(`[inventario] ${req.method} ${redactUrl(req.originalUrl)} desde ${req.ip} (metodo no soportado)`);
|
||||
res.status(405).json({ success: false, data: null, error: 'Usa POST' });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user