304 lines
12 KiB
JavaScript
304 lines
12 KiB
JavaScript
/**
|
|
* 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<string[]>}
|
|
*/
|
|
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],
|
|
};
|
|
}
|