/** * Hashing de campos `editor_password` para escrituras directas al cmsApi. * * CONTEXTO / POR QUE EXISTE ESTE FICHERO * En Acai, los campos de tipo `editor_password` (p.ej. `usuarios.clave`) se * almacenan hasheados con SHA1. Ese hasheo lo hace el plugin PHP * `cms/lib/plugins/editor_password/save_pre.php`, que es un hook del admin. * Las tools MCP escriben por `action_ws=cmsApi&subaction=insert|update`, y ese * camino NO ejecuta hooks de plugin: sin esta capa, el agente guardaria las * contrasenas EN CLARO en la base de datos. * * REGLA CANONICA (fuente de verdad: `server/password_fields.py`, la misma que * aplica el dashboard; espejo del plugin PHP save_pre.php): * - Valor almacenado = sha1(texto_plano) en hexadecimal. * - INSERT: hashear el valor si no esta vacio. * - UPDATE: hashear solo si difiere del valor ya almacenado (evita * re-hashear un hash reenviado, p.ej. tras un `get_record`). * - Valor vacio / ausente: NO tocar el campo (se elimina del payload) para * no borrar una contrasena existente. * * NOTAS Y LIMITACIONES CONOCIDAS * 1. En UPDATE leemos el valor almacenado con una lectura extra al cmsApi, y * solo cuando el payload trae realmente un campo password con valor. Si esa * lectura falla (o el `num` no es numerico), caemos a la heuristica * "si el valor entrante ya parece un sha1 (40 hex) se deja tal cual, si no * se hashea". Consecuencia: una contrasena en claro que sea exactamente 40 * caracteres hex se guardaria sin hashear en ese caso degradado. Se acepta * para cumplir la prioridad "nunca doble-hashear". * 2. En INSERT se hashea siempre (igual que el plugin PHP), asi que insertar * un hash copiado de otro registro produciria un doble hash. Es la * semantica canonica: en un insert no hay valor previo con el que comparar. * 3. El tipo `editor_password` solo se conoce leyendo el schema de la tabla. * Si el schema no se puede leer y el payload contiene un campo con nombre * sospechoso de password, se BLOQUEA la escritura en vez de arriesgar texto * en claro (o corromper datos hasheando un campo que no es password). */ import crypto from "node:crypto"; import { getApiClient } from "../../auth/index.js"; import { AcaiHttpClient } from "./acaiHttpClient.js"; import { parseIniSchema } from "../tables/iniParser.js"; const PASSWORD_FIELD_TYPE = "editor_password"; const SHA1_HEX_RE = /^[a-f0-9]{40}$/i; const SCHEMA_CACHE_TTL_MS = 60 * 1000; /** * Nombres tipicos de campo password. SOLO se usan como red de seguridad * cuando el schema no se pudo leer. Nunca deciden el hasheo cuando SI * tenemos schema (ahi manda `type = "editor_password"`). */ const PASSWORD_NAME_HINTS = [ "clave", "password", "passwd", "pwd", "pass", "contrasena", "contraseƱa", "secret", ]; /** * Cache de nombres de campos password por tabla. * Clave con entorno incluido (website + mode + project_dir) para no cruzar * datos entre proyectos/entornos distintos en el mismo proceso. */ const schemaCache = new Map(); export function sha1Hex(value) { return crypto.createHash("sha1").update(String(value), "utf8").digest("hex"); } export function looksLikeSha1(value) { return typeof value === "string" && SHA1_HEX_RE.test(value.trim()); } /** Vacio = no tocar el campo. El "0" cuenta como valor real (se hashea). */ function isEmptyValue(value) { if (value === undefined || value === null) return true; return typeof value === "string" && value.trim() === ""; } function looksLikePasswordName(fieldName) { const lower = String(fieldName).toLowerCase(); return PASSWORD_NAME_HINTS.some(hint => lower.includes(hint)); } function bareTableName(tableName) { return String(tableName).replace(/^cms_/, ""); } function buildCacheKey(credentials, tableName) { const website = credentials?.website || ""; const mode = credentials?.mode || ""; const projectDir = credentials?.project_dir || ""; return `${website}|${mode}|${projectDir}|${bareTableName(tableName)}`; } /** Util para tests y para invalidar tras cambios de schema. */ export function clearPasswordFieldsCache() { schemaCache.clear(); } /** * Devuelve los nombres de campo de tipo `editor_password` de una tabla. * Usa la misma via que `get_table_schema`/`list_tables`: * viewer_functions.php?action_ws=getTableSchemas + parseIniSchema. * * Cachea el resultado (incluida la lista vacia) para no anadir una llamada * HTTP por cada escritura sobre la misma tabla. * * @returns {Promise} */ export async function getPasswordFieldNames({ sessionId, credentials, tableName }) { const key = buildCacheKey(credentials, tableName); const cached = schemaCache.get(key); if (cached && cached.expiresAt > Date.now()) return cached.fields; const client = await getApiClient(sessionId); const response = await AcaiHttpClient.postViewerFunctions(client, { action_ws: "getTableSchemas", tableName, token: credentials?.token, tokenHash: credentials?.tokenHash, }); const schemas = response?.data?.schemas; if (!schemas || typeof schemas !== "object") { throw new Error("getTableSchemas no devolvio 'schemas'"); } // Tabla sin schema (tablas de sistema, cms_*): no puede tener campos // editor_password porque el tipo solo existe en el schema. const fields = []; const iniContent = schemas[`${bareTableName(tableName)}.ini.php`]; if (iniContent) { const parsed = parseIniSchema(iniContent); for (const [name, definition] of Object.entries(parsed.fields || {})) { if (definition && definition.type === PASSWORD_FIELD_TYPE) fields.push(name); } } schemaCache.set(key, { fields, expiresAt: Date.now() + SCHEMA_CACHE_TTL_MS }); return fields; } /** * Lee el registro almacenado para poder comparar el valor previo del campo * password. Best-effort: devuelve null si no se puede resolver. */ async function fetchStoredRecord({ credentials, tableName, recordId }) { // Solo aceptamos `num` numerico: es la PK entera y asi no interpolamos // texto arbitrario en el where de esta lectura. const numericId = Number(recordId); if (!Number.isInteger(numericId) || numericId <= 0) return null; const response = await AcaiHttpClient.postCmsApi( credentials, "get", { tableName, where: `num = ${numericId}`, limit: 1, options: { uploads: false, relations: false }, }, credentials?.token, credentials?.tokenHash ); const rows = response?.data?.data; return Array.isArray(rows) && rows[0] && typeof rows[0] === "object" ? rows[0] : null; } /** * Aplica la regla canonica de hasheo a los campos `editor_password` de los * records antes de construir el payload del cmsApi. * * No muta los records de entrada: devuelve copias nuevas. * * @param {object} args * @param {string} args.sessionId * @param {object} args.credentials * @param {string} args.tableName * @param {object[]} args.records * @param {*} [args.recordId] - presente => UPDATE; ausente => INSERT * @returns {Promise<{records: object[], hashedFields: string[], omittedEmptyFields: string[], warnings: string[], error?: string}>} */ export async function applyPasswordFieldHashing({ sessionId, credentials, tableName, records, recordId }) { const safeRecords = Array.isArray(records) ? records : []; const result = { records: safeRecords, hashedFields: [], omittedEmptyFields: [], warnings: [], }; const presentNames = new Set(); for (const record of safeRecords) { if (record && typeof record === "object" && !Array.isArray(record)) { Object.keys(record).forEach(name => presentNames.add(name)); } } if (presentNames.size === 0) return result; let passwordFields; try { passwordFields = await getPasswordFieldNames({ sessionId, credentials, tableName }); } catch (error) { // Sin schema no sabemos que campos son password. Si algun nombre huele // a password, bloqueamos: mejor fallar que guardar texto en claro. const suspicious = [...presentNames].filter(looksLikePasswordName); if (suspicious.length > 0) { return { ...result, error: `No se pudo leer el schema de '${tableName}' para verificar campos de contrasena (${error.message}). ` + `La escritura se ha cancelado porque el payload incluye ${suspicious.join(", ")} y los campos ` + `'editor_password' deben guardarse hasheados. Reintenta cuando el schema sea accesible.`, }; } console.error(`[passwordFields] schema de '${tableName}' no disponible (${error.message}); sin campos sospechosos, se continua`); return { ...result, warnings: [`Schema de '${tableName}' no verificado: ${error.message}`] }; } const fieldsInPayload = passwordFields.filter(name => presentNames.has(name)); if (fieldsInPayload.length === 0) return result; const isUpdate = Boolean(recordId); // Solo necesitamos el valor previo si algun record trae de verdad un valor // en un campo password (los vacios se eliminan sin comparar nada). const needsStoredValue = isUpdate && safeRecords.some(record => record && typeof record === "object" && !Array.isArray(record) && fieldsInPayload.some(field => field in record && !isEmptyValue(record[field])) ); let storedRecord = null; if (needsStoredValue) { try { storedRecord = await fetchStoredRecord({ credentials, tableName, recordId }); } catch (error) { console.error(`[passwordFields] no se pudo leer el registro ${recordId} de '${tableName}': ${error.message}`); } if (!storedRecord) { result.warnings.push( "No se pudo leer el valor previo de los campos de contrasena; se aplica el criterio " + "'hashear salvo que el valor ya sea un sha1 de 40 hex'." ); } } const hashedFields = new Set(); const omittedEmptyFields = new Set(); const nextRecords = safeRecords.map(record => { if (!record || typeof record !== "object" || Array.isArray(record)) return record; const next = { ...record }; for (const field of fieldsInPayload) { if (!(field in next)) continue; const incoming = next[field]; // Vacio => no tocar el campo (no borramos la contrasena existente). if (isEmptyValue(incoming)) { delete next[field]; omittedEmptyFields.add(field); continue; } const value = String(incoming); if (isUpdate && storedRecord) { const stored = storedRecord[field] === undefined || storedRecord[field] === null ? "" : String(storedRecord[field]); // Igual al almacenado => es el hash reenviado, se deja intacto. if (stored !== "" && value === stored) continue; next[field] = sha1Hex(value); hashedFields.add(field); continue; } if (isUpdate && !storedRecord) { // Camino degradado (ver limitacion 1 de la cabecera). if (looksLikeSha1(value)) continue; next[field] = sha1Hex(value); hashedFields.add(field); continue; } // INSERT: hashear siempre que haya valor. next[field] = sha1Hex(value); hashedFields.add(field); } return next; }); if (hashedFields.size > 0) { console.error(`[passwordFields] '${tableName}': campos hasheados (sha1) -> ${[...hashedFields].join(", ")}`); } return { ...result, records: nextRecords, hashedFields: [...hashedFields], omittedEmptyFields: [...omittedEmptyFields], }; }