diff --git a/mcp-server/tools/helpers/passwordFields.js b/mcp-server/tools/helpers/passwordFields.js new file mode 100644 index 0000000..a64f4d0 --- /dev/null +++ b/mcp-server/tools/helpers/passwordFields.js @@ -0,0 +1,303 @@ +/** + * 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], + }; +} diff --git a/mcp-server/tools/records/createUpdate.js b/mcp-server/tools/records/createUpdate.js index e4af54e..5dfa55b 100644 --- a/mcp-server/tools/records/createUpdate.js +++ b/mcp-server/tools/records/createUpdate.js @@ -2,9 +2,9 @@ import { z } from "zod"; import { withAuth, getSessionCredentials } from "../../auth/index.js"; import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js"; import { AcaiHttpClient } from "../helpers/acaiHttpClient.js"; -import { table } from "console"; import { withAuthParams } from "../helpers/authSchema.js"; import { canAccessTable } from "../helpers/accessControl.js"; +import { applyPasswordFieldHashing } from "../helpers/passwordFields.js"; export function registerCreateOrUpdateRecordTool(server) { server.tool( @@ -13,7 +13,9 @@ export function registerCreateOrUpdateRecordTool(server) { Reglas clave: tablas sin prefijo 'cms_'; PK es 'num' (nunca 'id'); foreign keys con sufijo '_num'; uploads son arrays — NO los envíes en 'fields', sube después con 'upload_record_image'; fechas en formato YYYY-MM-DD HH:mm:ss; checkboxes como 1/0 (números). -Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null, builder:"[]", controlador, precontrolador, breadcrumb, enlace. NUNCA modifiques 'enlace' ni 'controlador' de un registro existente — los stripeo automáticamente en updates.`, +Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null, builder:"[]", controlador, precontrolador, breadcrumb, enlace. NUNCA modifiques 'enlace' ni 'controlador' de un registro existente — los stripeo automáticamente en updates. + +Contraseñas: los campos de tipo 'editor_password' (e.g. 'usuarios.clave') se hashean automáticamente con SHA1 antes de guardarse — envía la contraseña en texto plano y NO la hashees tú. Su valor no se puede leer/descifrar después (solo verás el hash), así que no intentes recuperar contraseñas existentes ni reenviarlas. Si envías el campo vacío ('' o null) se omite del guardado y la contraseña actual se mantiene.`, withAuthParams({ tableName: z.string().describe("Nombre de la tabla sin prefijo 'cms_' (e.g. 'productos', 'apartados')"), recordId: z.any().optional().describe("'num' del registro a actualizar. Omitir para crear nuevo. NO se usa cuando 'fields' es array."), @@ -87,8 +89,27 @@ Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null }); } - // Prepare payload for CMS API const credentials = await getSessionCredentials(extra.sessionId); + + // Campos de contraseña (type = "editor_password"): el cmsApi no ejecuta + // hooks de plugin, así que el hasheo sha1 que haría save_pre.php hay que + // aplicarlo aquí. Regla canónica en server/password_fields.py. + const passwordResult = await applyPasswordFieldHashing({ + sessionId: extra.sessionId, + credentials, + tableName, + records: processedRecords, + recordId, + }); + if (passwordResult.error) { + return { + content: [{ type: "text", text: JSON.stringify({ success: false, error: passwordResult.error }, null, 2) }], + isError: true, + }; + } + processedRecords = passwordResult.records; + + // Prepare payload for CMS API const recordPayload = { tableName: tableName, records: processedRecords, @@ -139,6 +160,12 @@ Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null recordIds: response.data?.data || (recordId || 'new'), recordsCount: isArray ? recordsArray.length : 1, createdIds: response.data?.data, + // Transparencia sobre el tratamiento de contraseñas (nunca devolvemos el valor) + passwordFieldsHashed: passwordResult.hashedFields.length > 0 ? passwordResult.hashedFields : undefined, + passwordFieldsSkipped: passwordResult.omittedEmptyFields.length > 0 + ? `Campos de contraseña vacíos, omitidos para no borrar el valor actual: ${passwordResult.omittedEmptyFields.join(', ')}` + : undefined, + passwordWarnings: passwordResult.warnings.length > 0 ? passwordResult.warnings : undefined, suggestion: isNewRecord && !isArray ? `You can verify the record by fetching: ${credentials.web_url}${processedRecords[0].enlace}` : undefined }, null, 2) }],