Articolo tecnico

HotXLS: callback di formule unsafe sotto controllo

I motori di foglio elettronico valutano gli argomenti prima di chiamare la funzione che li riceve. Una cella che contiene =WEBSERVICE(AUDIT_TOKEN()) esegue quindi AUDIT_TOKEN per primo, e se l'applicazione host risponde a AUDIT_TOKEN tramite un handler di funzioni definite dall'utente, quel handler ha già scritto la sua riga di log, incrementato il suo contatore o aperto la sua connessione quando qualcuno guarda la parola WEBSERVICE. Una blocklist dentro il handler arriva troppo tardi. Arriva troppo tardi anche una che ispeziona il nome esterno dopo che gli argomenti sono tornati. Il rifiuto va deciso sul nome esterno, prima che qualsiasi argomento venga toccato e prima che qualsiasi codice scritto dall'host riceva il suo turno

Il contesto dove questo morde è banale. Un servizio accetta file XLS o XLSX caricati, li ricalcola sul server e rilegge qualche totale. Anni fa qualcuno ha registrato un handler di funzioni utente per due funzioni di business, e nel frattempo gli è cresciuto un ramo catch-all che inoltra qualsiasi nome non riconosciuto a una tabella di plugin. Nessuno nel team ha mai digitato =WEBSERVICE(...) in una cella. L'uploader sì. Tenere quella formula intatta attraverso open, recalc e save è un compito di file fidelity; lasciarla arrivare a codice host capace di aprire socket o scrivere file è una decisione di autorizzazione, e le due cose servono responsabili diversi

Come una formula preservata arriva ai vostri callback Delphi

Nel motore di formule di HotXLS, qualsiasi chiamata che il calcolatore non può valutare in modo nativo cade in un'unica catena di dispatch condivisa, e i nomi pericolosi arrivano lì per due strade. Una è quella delle funzioni built-in che il parser riconosce ma per le quali il calcolatore non ha implementazione: CALL e REGISTER.ID sono nomi di funzioni Excel reali, quindi si parsano puliti e poi atterrano nella stessa fallback del vostro DISCOUNT o REGIONRATE. L'altra è quella dei nomi che il parser non conosce affatto, come WEBSERVICE o DDE, che possono combaciare con una voce omonima in un registro o essere rivendicati da un handler di evento catch-all. La meccanica di quella fallback è coperta in come HotXLS risolve le funzioni personalizzate tramite OnUserFunction

L'ordine della catena decide che cosa significa «sconosciuto». Un nome va prima di tutto alla macchina LAMBDA: una closure legata da LET, o un nome definito la cui formula è una LAMBDA, entrambi descritti in il supporto delle closure nel motore di formule di HotXLS. Vengono poi le funzioni locali del workbook registrate con RegisterUserFunction, seguite dalle funzioni a livello di processo dal metodo di classe TXLSWorkbook.RegisterGlobalUserFunction, e per ultime gli eventi. OnUserFunctionEx gira quando è assegnato; OnUserFunction gira solo quando OnUserFunctionEx non lo è. Un nome davvero sconosciuto diventa #NAME? quando ogni stage ha declinato, mentre un built-in riconosciuto ma non implementato riporta una funzione non supportata

Ogni stage dopo la ricerca LAMBDA passa il controllo a codice scritto da voi. Ecco perché HotXLS mette il suo controllo di sicurezza davanti a ogni stage che raggiunge il vostro codice anziché dentro uno qualsiasi di essi, e perché il controllo sta nel calcolatore stesso anziché nei tre lettori di file. Una formula caricata da token Classic BIFF, da testo XLSX o da una formula ODS msoxl: finisce come la stessa espressione parsata, quindi un solo controllo copre tutti e tre i formati e un workbook convertito si comporta come l'originale

Come HotXLS filtra i callback di formule unsafe: il nome della funzione viene controllato contro il deny set prima che l'array di argomenti venga costruito o qualsiasi resolver giri, così un =WEBSERVICE(AUDIT_TOKEN()) annidato viene rifiutato con xlfeUnsafeFunctionDenied e il log di audit resta vuoto, mentre le chiamate sicure percorrono la catena dai binding LAMBDA e LET fino a OnUserFunction
Solo quando ogni stage declina una funzione davvero sconosciuta diventa #NAME?, ed è per questo che il controllo di sicurezza sta davanti agli stage di callback anziché dentro uno qualsiasi dei handler

Quali nomi di formule nega HotXLS per default?

HotXLS nega un insieme fisso di 20 nomi a meno che i callback non siano esplicitamente consentiti: 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 e FILE.DELETE. In Excel o nel suo linguaggio macro questi sono i nomi che caricano codice nativo, raggiungono la rete, parlano ad altri processi o toccano il file system. Prima del confronto, il motore taglia gli spazi ai bordi, mette il nome in maiuscolo e rimuove un singolo prefisso _XLFN. o _XLWS.. Un file scritto da una build di Excel più recente che conserva _xlfn.webservice viene colto esattamente come la grafia nuda

Come HotXLS normalizza un nome di funzione prima del confronto dei callback unsafe: gli spazi ai bordi vengono rimossi, il nome viene messo in maiuscolo e un singolo prefisso _XLFN. o _XLWS. viene tolto così _xlfn.webservice viene colto come la grafia nuda, quindi il risultato viene confrontato esattamente contro il deny set fisso di 20 nomi
La lista copre i nomi che caricano codice nativo, raggiungono la rete, parlano ad altri processi o toccano il file system, da DDE, CALL e WEBSERVICE fino a FWRITE e FILE.DELETE

Il confronto è esatto, il che taglia in entrambe le direzioni. Un handler che registrate come MYWEBSERVICE non è toccato. Una UDF interna che per caso si chiama OPEN o RUN viene negata per default, e la riparazione allettante è impostare AllowUnsafeFormulaCallbacks a True sul workbook così quel nome torna a funzionare. Rinominate invece la funzione. Consentire l'opt-in dell'intero workbook per salvare un nome interno riattiva anche CALL, DDE e FILE.DELETE per ogni formula in quel file, comprese le formule che non avete scritto voi

Il deny set inoltre non recinta in alcun modo i vostri handler. Se un ramo catch-all esegue nomi di plugin arbitrari, il filtro ferma i 20 nomi pericolosi noti e lascia passare tutto il resto al vostro codice. La risposta duratura è smettere di scrivere handler catch-all. Registrare ogni funzione per nome con RegisterUserFunction trasforma il registro stesso in una allowlist, e tiene significativo il controllo di entrata delle formule: quando OnUserFunction o OnUserFunctionEx è assegnato, il motore non può sapere quali nomi l'evento rivendicherà, quindi la validazione di entrata smette del tutto di segnalare i nomi di funzione sconosciuti

procedure TImportService.WireFunctions;
begin
  // Una registrazione per nome: il registro è la allowlist
  FBook.RegisterUserFunction('DISCOUNT', HandleDiscount);
  FBook.RegisterUserFunction('REGIONRATE', HandleRegionRate);
  // Lascia entrambi gli eventi non assegnati così i nomi sconosciuti
  // restano rifiutati all'entrata e finiscono come #NAME? alla valutazione
  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 è un variant array a base zero di argomenti già valutati
  if VarIsArray(Args) and (VarArrayHighBound(Args, 1) = 1) then
  begin
    Value := Args[0] * (1 - Args[1]);
    Handled := True;
  end;
end;

Perché il filtro deve girare prima della valutazione degli argomenti?

HotXLS controlla prima il nome della funzione e rifiuta la chiamata prima di costruire l'array di argomenti, prima di consultare un resolver o uno dei due registri, e perfino prima di notare che nessun handler è assegnato. Quell'ordine è ciò che sconfigge la formula annidata dell'apertura: il WEBSERVICE esterno viene rifiutato mentre AUDIT_TOKEN è ancora un nodo figlio non valutato, quindi il handler host non gira mai. L'ultima parte dell'ordine conta per il logging. Un workbook senza handler e un workbook con cinque handler riportano lo stesso stato per =WEBSERVICE(...), così un log di rifiuti non cambia significato quando qualcuno più avanti collega un handler

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');   // effetto collaterale in codice host
    Value := 'token-42';
    Handled := True;
  end;
end;

procedure TImportService.ProbeNestedCall;
var
  Sheet: TXLSWorksheet;
  Eval: TXLSFormulaEvaluationResult;
begin
  FBook.OnUserFunction := HandleUdf;
  Sheet := FBook.Sheets[1];                  // gli indici dei worksheet partono da 1
  Eval := Sheet.EvaluateFormulaAt(1, 1, '=WEBSERVICE(AUDIT_TOKEN())');
  // Atteso: Eval.Status = xlfeUnsafeFunctionDenied, Eval.Value = Null,
  // Eval.Issue.NativeCode = -106 e FAuditLog.Count = 0
end;

Il codice nativo -106 è la costante pubblica lxErrorUnsafeFunctionDenied, e Eval.Issue.Message legge "The formula callback is blocked by the safety policy". xlfeUnsafeFunctionDenied è l'ultimo membro di TXLSFormulaEvaluationStatus, dopo xlfeFailed. Codice che mappa gli stati con un case e un ramo else per il fallimento generico archivierà un rifiuto sotto «valutazione fallita» a meno che non nomini lo stato esplicitamente, e per una pipeline di upload è proprio quell'esito che vale la pena riportare a chi ha caricato il file

Default del workbook contro TXLSFormulaEvaluationOptions per chiamata

Il flag del workbook imposta il default e l'opzione per chiamata ha l'ultima parola. TXLSWorkbook.AllowUnsafeFormulaCallbacks e TXLSXWorkbook.AllowUnsafeFormulaCallbacks partono da False e governano il ricalcolo ordinario, Calculate, la EvaluateFormulaAt a tre argomenti, EvaluateAt su un evaluation template, le formule degli elenchi di data validation, le viste di sola lettura e, su XLSX, ogni worker nel pool di ricalcolo parallelo. Qualsiasi punto di ingresso che prende un record TXLSFormulaEvaluationOptions esplicito usa Options.AllowUnsafeFormulaCallbacks come verdetto per quella chiamata e non lo mette in OR con la proprietà del workbook

Quella asimmetria è deliberata, ed è il default giusto. Un processo interno fidato può autorizzare una ricerca RTD senza ribaltare l'intero workbook, e un workbook consentito globalmente può comunque forzare una valutazione sensata al rifiuto. XLSDefaultFormulaEvaluationOptions restituisce il flag come False, quindi costruire il record da quella funzione significa rifiuto, a meno che non lo impostiate di proposito. Un solo percorso ignora del tutto il flag: quando le regole di conditional formatting Classic vengono valutate per il rendering, i callback vengono negati qualunque cosa dica il workbook

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

  // il workbook resta bloccato, una sola chiamata fidata viene fatta passare
  FBook.AllowUnsafeFormulaCallbacks := False;
  Options := XLSDefaultFormulaEvaluationOptions;
  Options.AllowUnsafeFormulaCallbacks := True;
  Eval := Sheet.EvaluateFormulaAt(4, 2, '=WEBSERVICE(B1)', xlfrsA1, Options);

  // il workbook ha acconsentito, ma questa valutazione di testo caricato no
  FBook.AllowUnsafeFormulaCallbacks := True;
  Options := XLSDefaultFormulaEvaluationOptions;   // il flag è di nuovo False
  Eval := Sheet.EvaluateFormulaAt(4, 2, UploadedFormula, xlfrsA1, Options);
  if Eval.Status = xlfeUnsafeFunctionDenied then
    LogRejected(Eval.Issue.Message);   // LogRejected è una routine vostra
end;

Cambiare la proprietà del workbook segna inoltre come sporca ogni formula nel grafo delle dipendenze, su entrambe le classi di workbook. Senza di ciò, un valore messo in cache mentre i callback erano consentiti potrebbe essere servito dopo che sono stati revocati, o un rifiuto in cache potrebbe sopravvivere a un opt-in. Impostare la proprietà al valore che ha già è un no-op e lascia in pace la cache, quindi alternarla dentro un ciclo costa un ricalcolo completo solo quando il valore cambia davvero

Che cosa succede a testo di formule sconosciute e unsafe al salvataggio?

Conservare una formula ed eseguirla sono due domande separate, e la policy di entrata risponde solo alla prima. FormulaEntryPolicy su entrambe le classi di workbook porta UnknownFunctionMode e UnknownNameMode, entrambi con default xlfusmReject. Assegnare una formula con una chiamata sconosciuta tramite la normale proprietà Formula solleva EArgumentException con "Formula entry rejected: unknown function" prima che il valore della cella, la cache delle formule o le dipendenze cambino. ValidateFormulaEntry restituisce la stessa decisione come record, senza effetti collaterali. I percorsi fidati come caricare un file, copiare e la conversione di formato non passano per la policy di entrata utente, perché un default severo non deve mai rifiutare simboli già presenti in un file che state solo aprendo

var
  Sheet: TXLSWorksheet;
  Policy: TXLSFormulaEntryPolicy;
  Check: TXLSFormulaEntryValidationResult;
begin
  Sheet := FBook.Sheets[1];
  Check := Sheet.ValidateFormulaEntry(3, 1, '=ACME_RATE(B3)');
  // policy di default, nessun handler per ACME_RATE:
  // Check.Issue = xlfeiUnknownFunction, Check.Action = xlfeaReject

  Policy := FBook.FormulaEntryPolicy;
  Policy.UnknownFunctionMode := xlfusmPreserve;   // entrata per compatibilità
  FBook.FormulaEntryPolicy := Policy;
  Sheet.Cells[3, 1].Formula := '=ACME_RATE(B3)';  // conservata, non autorizzata
  FBook.SaveAs('rates.xls');
end;

Il BIFF8 classico non ha un token per un nome di funzione arbitrario, quindi HotXLS scrive una chiamata sconosciuta nel modo in cui Excel scrive le funzioni add-in. La formula riceve un token PtgNameX ($59 la cui voce XTI punta al SUPBOOK degli add-in con entrambi gli indici di foglio impostati a $FFFE, seguita dai token degli argomenti e da un PtgFuncVar che porta il numero di funzione 255 e un conteggio di argomenti che include lo slot del nome. Il corpo ExternName che sta dietro è di sei byte a zero, un byte di lunghezza e il flag Unicode, il nome della funzione in UTF-16, poi una formula di due byte $1C $17, un PtgErr che contiene #REF!. Lo scrittore accetta nomi da 1 a 255 caratteri con al massimo 29 argomenti, e mira solo al BIFF8; un salvataggio BIFF5 non può portare la chiamata. Come queste voci SUPBOOK degli add-in stanno accanto ai collegamenti a workbook esterni è spiegato in le regole di classificazione SUPBOOK e XTI per i collegamenti esterni BIFF

Come HotXLS scrive una chiamata di formula sconosciuta nel BIFF8 classico: la formula porta un token PtgNameX la cui voce XTI punta al SUPBOOK degli add-in con entrambi gli indici di foglio $FFFE, poi i token degli argomenti e un PtgFuncVar con numero di funzione 255, retto da un corpo ExternName che termina in un PtgErr di due byte $1C $17 contenente #REF!
XLSX conserva il testo grezzo della funzione e ODS conserva la sua formula msoxl:, quindi un file che ha salvato =WEBSERVICE(...) si riapre con il testo intatto e resta per default in valutazione a xlfeUnsafeFunctionDenied

XLSX conserva il testo grezzo della funzione e ODS conserva la sua formula msoxl:. In tutti e tre i formati un file che ha salvato =WEBSERVICE(...) si riapre con il nome intatto, e la sua valutazione predefinita restituisce ancora xlfeUnsafeFunctionDenied senza chiamare l'host. Il salvataggio non promuove mai i permessi di una formula: lo stesso testo rifiutato prima del round trip è rifiutato dopo

Per un server che ricalcola workbook non scritti da lui, la configurazione che regge è breve: lasciate AllowUnsafeFormulaCallbacks a False, registrate le funzioni per nome invece di assegnare un evento catch-all, e passate opzioni per chiamata solo dove il testo della formula viene dal vostro codice. Il riferimento a callback, policy di entrata e valutazione viaggia con la documentazione dell'HotXLS Delphi spreadsheet component