Artículo técnico

Fórmulas inseguras en HotXLS: frenar CALL y WEBSERVICE

Los motores de hojas de cálculo evalúan los argumentos antes de llamar a la función que los recibe. Una celda con =WEBSERVICE(AUDIT_TOKEN()) corre entonces AUDIT_TOKEN primero, y si la aplicación host responde AUDIT_TOKEN mediante un handler de funciones definidas por el usuario, ese handler ya escribió su línea de log, aumentó su contador o abrió su conexión para cuando algo mira la palabra WEBSERVICE. Una blocklist dentro del handler llega tarde. Y también llega tarde una que inspecciona el nombre externo después de que los argumentos vuelven. El rechazo tiene que decidirse sobre el nombre externo, antes de tocar cualquier argumento y antes de que le toque el turno a cualquier código que el host escribió

El escenario donde esto muerde es mundano. Un servicio acepta archivos XLS o XLSX subidos, los recalcula en el servidor y lee de vuelta unos cuantos totales. Hace años alguien registró un handler de funciones de usuario para dos funciones de negocio, y en el camino le creció una rama catch-all que reenvía cualquier nombre no reconocido a una tabla de plugins. Nadie del equipo tipeó jamás =WEBSERVICE(...) en una celda. La persona que subió el archivo, sí. Conservar esa fórmula intacta a través de abrir, recalcular y guardar es un trabajo de fidelidad de archivo; dejar que llegue a código host que puede abrir sockets o escribir archivos es una decisión de autorización, y las dos cosas necesitan dueños distintos

Cómo una fórmula preservada llega a sus callbacks de Delphi

En el motor de fórmulas de HotXLS, cualquier llamada que la calculadora no puede evaluar de forma nativa cae en una cadena de despacho compartida, y los nombres peligrosos llegan ahí por dos caminos. Un camino son las funciones built-in que el parser reconoce pero para las que la calculadora no tiene implementación: CALL y REGISTER.ID son nombres de función reales de Excel, así que parsean limpio y caen en el mismo fallback que su DISCOUNT o REGIONRATE. El otro camino son nombres que el parser no conoce en absoluto, como WEBSERVICE o DDE, que pueden coincidir con una entrada homónima en un registro o ser reclamados por un event handler catch-all. La mecánica de ese fallback está cubierta en cómo HotXLS resuelve funciones personalizadas vía OnUserFunction

El orden de la cadena decide lo que «desconocido» significa. Un nombre va primero a la maquinaria LAMBDA: un closure ligado por LET, o un nombre definido cuya fórmula es una LAMBDA, ambos descritos en el soporte de closures del motor de fórmulas de HotXLS. Después vienen las funciones locales del libro registradas con RegisterUserFunction, seguidas de las funciones de proceso desde el método de clase TXLSWorkbook.RegisterGlobalUserFunction, y al final los eventos. OnUserFunctionEx corre cuando está asignado; OnUserFunction corre solo cuando OnUserFunctionEx no lo está. Un nombre realmente desconocido se vuelve #NAME? una vez que todas las etapas declinaron, mientras que un built-in reconocido pero sin implementar reporta una función no soportada

Cada etapa posterior al lookup de LAMBDA le entrega el control a código que usted escribió. Por eso HotXLS pone su chequeo de seguridad delante de cada etapa que alcanza su código en lugar de dentro de cualquiera de ellas, y por qué el chequeo vive en la calculadora misma y no en los tres lectores de archivos. Una fórmula cargada desde tokens Classic BIFF, desde texto XLSX o desde una fórmula msoxl: de ODS termina como la misma expresión parseada, así que un solo chequeo cubre los tres formatos y un libro convertido se comporta igual que el original

Cómo HotXLS frena los callbacks de fórmulas inseguras: el nombre de la función se verifica contra el conjunto de denegación antes de que se construya el arreglo de argumentos o corra cualquier resolver, así que un =WEBSERVICE(AUDIT_TOKEN()) anidado se rehúsa con xlfeUnsafeFunctionDenied y el log de auditoría queda vacío, mientras que las llamadas seguras recorren la cadena desde los bindings LAMBDA y LET hasta OnUserFunction
Solo cuando todas las etapas declinan se vuelve #NAME? una función realmente desconocida, que es por qué el chequeo de seguridad va delante de toda la cadena y no dentro de un handler cualquiera

¿Qué nombres de función bloquea HotXLS por defecto?

HotXLS deniega un conjunto fijo de 20 nombres a menos que los callbacks estén explícitamente permitidos: DDE, CALL, REGISTER, REGISTER.ID, WEBSERVICE, RTD, SQL.REQUEST, EXEC, RUN, CREATE.OBJECT, APP.ACTIVATE, SEND.KEYS, OPEN, SAVE, SAVE.AS, FOPEN, FWRITE, FWRITELN, FCLOSE y FILE.DELETE. En Excel o su lenguaje de macros, estos son los nombres que cargan código nativo, alcanzan la red, hablan con otros procesos o tocan el sistema de archivos. Antes de comparar, el motor recorta los espacios de los bordes, pasa el nombre a mayúsculas y quita un único prefijo _XLFN. o _XLWS.. Un archivo escrito por un build de Excel más nuevo que guarda _xlfn.webservice se atrapa exactamente igual que la forma pelada

Cómo HotXLS normaliza un nombre de función antes de la comparación de callback inseguro: se recortan los espacios, el nombre pasa a mayúsculas y se quita un único prefijo _XLFN. o _XLWS. de modo que _xlfn.webservice se atrapa como la forma pelada, y luego el resultado se compara exactamente contra el conjunto fijo de denegación de 20 nombres
La lista abarca los nombres que cargan código nativo, tocan la red, hablan con otros procesos o tocan el sistema de archivos, desde DDE, CALL y WEBSERVICE hasta FWRITE y FILE.DELETE

La coincidencia es exacta, lo que corta en ambos sentidos. Un handler que usted registre como MYWEBSERVICE no se ve afectado. Una UDF interna que casualmente se llame OPEN o RUN queda denegada por defecto, y la reparación tentadora es poner AllowUnsafeFormulaCallbacks en True en el libro para que ese nombre vuelva a funcionar. Renombre la función en su lugar. Habilitar el libro entero para rescatar un nombre interno también vuelve a activar CALL, DDE y FILE.DELETE para cada fórmula de ese archivo, incluidas las fórmulas que usted no escribió

El conjunto de denegación tampoco hace nada por cercar a sus propios handlers. Si una rama catch-all ejecuta nombres de plugins arbitrarios, el gate frena los 20 peligrosos famosos y deja pasar todo lo demás hasta su código. La respuesta durable es dejar de escribir handlers catch-all. Registrar cada función por nombre con RegisterUserFunction convierte al registro mismo en una allowlist, y mantiene con sentido el chequeo de entrada de fórmulas: cuando OnUserFunction o OnUserFunctionEx está asignado, el motor no puede saber qué nombres va a reclamar el evento, así que la validación de entrada deja de marcar nombres de función desconocidos por completo

procedure TImportService.WireFunctions;
begin
  // Un registro por nombre: el registry es la allowlist
  FBook.RegisterUserFunction('DISCOUNT', HandleDiscount);
  FBook.RegisterUserFunction('REGIONRATE', HandleRegionRate);
  // Deje ambos eventos sin asignar para que los nombres desconocidos sigan
  // rechazándose en la entrada y terminando como #NAME? en la evaluación
  FBook.OnUserFunction := nil;
  FBook.OnUserFunctionEx := nil;
end;

procedure TImportService.HandleDiscount(Sender: TObject;
  const FunctionName: WideString; const Args: Variant;
  var Value: Variant; var Handled: Boolean);
begin
  // Args es un variant array base 0 de argumentos ya evaluados
  if VarIsArray(Args) and (VarArrayHighBound(Args, 1) = 1) then
  begin
    Value := Args[0] * (1 - Args[1]);
    Handled := True;
  end;
end;

¿Por qué el gate debe correr antes de evaluar los argumentos?

HotXLS verifica primero el nombre de la función y se rehúsa a la llamada antes de construir el arreglo de argumentos, antes de consultar un resolver o cualquiera de los registros, e incluso antes de notar que no hay ningún handler asignado. Ese orden es lo que derrota a la fórmula anidada desde el arranque: el WEBSERVICE externo se rehúsa mientras AUDIT_TOKEN sigue siendo un nodo hijo sin evaluar, así que el handler del host nunca corre. La última parte del orden importa para el logeo. Un libro sin handlers y un libro con cinco reportan el mismo estado para =WEBSERVICE(...), así que un log de rechazos no cambia de significado cuando alguien conecta un handler más tarde

procedure TImportService.HandleUdf(Sender: TObject;
  const FunctionName: WideString; const Args: Variant;
  var Value: Variant; var Handled: Boolean);
begin
  if SameText(FunctionName, 'AUDIT_TOKEN') then
  begin
    FAuditLog.Add('AUDIT_TOKEN evaluated');   // efecto secundario en código host
    Value := 'token-42';
    Handled := True;
  end;
end;

procedure TImportService.ProbeNestedCall;
var
  Sheet: TXLSWorksheet;
  Eval: TXLSFormulaEvaluationResult;
begin
  FBook.OnUserFunction := HandleUdf;
  Sheet := FBook.Sheets[1];                  // los índices de worksheet arrancan en 1
  Eval := Sheet.EvaluateFormulaAt(1, 1, '=WEBSERVICE(AUDIT_TOKEN())');
  // Esperado: Eval.Status = xlfeUnsafeFunctionDenied, Eval.Value = Null,
  // Eval.Issue.NativeCode = -106 y FAuditLog.Count = 0
end;

El código nativo -106 es la constante pública lxErrorUnsafeFunctionDenied, y Eval.Issue.Message dice «The formula callback is blocked by the safety policy». xlfeUnsafeFunctionDenied es el último miembro de TXLSFormulaEvaluationStatus, después de xlfeFailed. El código que mapea estados con un case y una rama else para fallo genérico va a archivar una denegación bajo «fallo de evaluación» a menos que nombre el estado explícitamente, y para un pipeline de subidas ese es justamente el desenlace que vale la pena reportarle a quien subió el archivo

Default del libro contra TXLSFormulaEvaluationOptions por llamada

La bandera del libro fija el default y la opción por llamada tiene la última palabra. TXLSWorkbook.AllowUnsafeFormulaCallbacks y TXLSXWorkbook.AllowUnsafeFormulaCallbacks arrancan en False y gobiernan el recálculo ordinario, Calculate, el EvaluateFormulaAt de tres argumentos, EvaluateAt sobre una plantilla de evaluación, las fórmulas de listas de validación de datos, las vistas de solo lectura y, en XLSX, cada worker del pool de recálculo paralelo. Cualquier punto de entrada que acepta un record TXLSFormulaEvaluationOptions explícito usa Options.AllowUnsafeFormulaCallbacks como veredicto para esa llamada y no lo mezcla con OR junto a la propiedad del libro

Esa asimetría es deliberada, y es el default correcto. Un trabajo interno de confianza puede autorizar un lookup RTD sin voltear el libro entero, y un libro globalmente habilitado puede igual forzar una evaluación sensible de vuelta a denegar. XLSDefaultFormulaEvaluationOptions devuelve la bandera como False, así que construir el record desde esa función significa denegación a menos que la active a propósito. Un camino ignora la bandera por completo: cuando las reglas Classic de formato condicional se evalúan para renderizar, los callbacks se deniegan sin importar lo que diga el libro

var
  Sheet: TXLSWorksheet;
  Options: TXLSFormulaEvaluationOptions;
  Eval: TXLSFormulaEvaluationResult;
begin
  Sheet := FBook.Sheets[1];

  // el libro queda cerrado, una llamada de confianza pasa
  FBook.AllowUnsafeFormulaCallbacks := False;
  Options := XLSDefaultFormulaEvaluationOptions;
  Options.AllowUnsafeFormulaCallbacks := True;
  Eval := Sheet.EvaluateFormulaAt(4, 2, '=WEBSERVICE(B1)', xlfrsA1, Options);

  // el libro está habilitado, pero esta evaluación de texto subido no
  FBook.AllowUnsafeFormulaCallbacks := True;
  Options := XLSDefaultFormulaEvaluationOptions;   // la bandera vuelve a False
  Eval := Sheet.EvaluateFormulaAt(4, 2, UploadedFormula, xlfrsA1, Options);
  if Eval.Status = xlfeUnsafeFunctionDenied then
    LogRejected(Eval.Issue.Message);   // LogRejected es su propia rutina
end;

Cambiar la propiedad del libro también marca cada fórmula del grafo de dependencias como sucia, en ambas clases de libro. Sin eso, un valor cacheado mientras los callbacks estaban permitidos podría servirse después de que los revocaran, o una denegación cacheada podría sobrevivir a una activación. Poner la propiedad en el valor que ya tiene es un no-op y deja la caché tranquila, así que alternarla dentro de un bucle solo cuesta un recálculo completo cuando el valor realmente cambia

¿Qué pasa con el texto de fórmulas desconocidas e inseguras al guardar?

Conservar una fórmula y ejecutarla son dos preguntas separadas, y la política de entrada solo responde la primera. FormulaEntryPolicy en cualquiera de las clases de libro lleva UnknownFunctionMode y UnknownNameMode, ambos con default xlfusmReject. Asignar una fórmula con una llamada desconocida a través de la propiedad Formula normal lanza un EArgumentException con «Formula entry rejected: unknown function» antes de que cambien el valor de la celda, la caché de fórmulas o las dependencias. ValidateFormulaEntry devuelve la misma decisión como record, sin efectos secundarios. Los caminos de confianza como cargar un archivo, copiar y convertir formato no pasan por la política de entrada de usuario, porque un default estricto jamás debe rechazar símbolos ya presentes en un archivo que usted apenas está abriendo

var
  Sheet: TXLSWorksheet;
  Policy: TXLSFormulaEntryPolicy;
  Check: TXLSFormulaEntryValidationResult;
begin
  Sheet := FBook.Sheets[1];
  Check := Sheet.ValidateFormulaEntry(3, 1, '=ACME_RATE(B3)');
  // política por defecto, sin handler para ACME_RATE:
  // Check.Issue = xlfeiUnknownFunction, Check.Action = xlfeaReject

  Policy := FBook.FormulaEntryPolicy;
  Policy.UnknownFunctionMode := xlfusmPreserve;   // entrada por compatibilidad
  FBook.FormulaEntryPolicy := Policy;
  Sheet.Cells[3, 1].Formula := '=ACME_RATE(B3)';  // se almacena, no está autorizada
  FBook.SaveAs('rates.xls');
end;

Classic BIFF8 no tiene token para un nombre de función arbitrario, así que HotXLS escribe una llamada desconocida de la manera en que Excel escribe las funciones de add-in. La fórmula recibe un token PtgNameX ($59) cuya entrada XTI apunta al SUPBOOK de add-ins con ambos índices de hoja en $FFFE, seguido de los tokens de argumentos y un PtgFuncVar que carga el número de función 255 y un conteo de argumentos que incluye la ranura del nombre. El cuerpo ExternName de respaldo son seis bytes en cero, un byte de longitud y la bandera Unicode, el nombre de función en UTF-16, y luego una fórmula de dos bytes $1C $17, un PtgErr que carga #REF!. El escritor acepta nombres de 1 a 255 caracteres con a lo sumo 29 argumentos, y apunta solo a BIFF8; un guardado BIFF5 no puede cargar la llamada. Cómo estas entradas SUPBOOK de add-ins se sientan junto a los enlaces a libros externos está explicado en las reglas de clasificación SUPBOOK y XTI de los enlaces externos BIFF

Cómo HotXLS escribe una llamada de fórmula desconocida en BIFF8 clásico: la fórmula lleva un token PtgNameX cuya entrada XTI apunta al SUPBOOK de add-ins con ambos índices de hoja $FFFE, luego los tokens de argumentos y un PtgFuncVar con número de función 255, respaldado por un cuerpo ExternName que termina en un PtgErr de dos bytes $1C $17 que carga #REF!
XLSX conserva el texto crudo de la función y ODS conserva su fórmula msoxl:, así que un archivo que guardó =WEBSERVICE(...) reabre con el texto intacto y sigue evaluando a xlfeUnsafeFunctionDenied por defecto

XLSX conserva el texto crudo de la función y ODS conserva su fórmula msoxl:. En los tres formatos un archivo que guardó =WEBSERVICE(...) reabre con el nombre intacto, y su evaluación por defecto sigue devolviendo xlfeUnsafeFunctionDenied sin llamar al host. Guardar nunca mejora los permisos de una fórmula: el mismo texto que se denegó antes del viaje de ida y vuelta se deniega después

Para un servidor que recalcula libros que no escribió él, la configuración que aguanta es corta: deje AllowUnsafeFormulaCallbacks en False, registre las funciones por nombre en lugar de asignar un evento catch-all, y pase opciones por llamada solo donde el texto de la fórmula viene de su propio código. La referencia de callbacks, política de entrada y evaluación viene con la documentación del componente de hojas de cálculo HotXLS para Delphi