Artykuł techniczny

Callbacki formuł HotXLS: blokada CALL i WEBSERVICE

Silniki arkuszowe liczą argumenty, zanim zawołają funkcję, która je dostaje. Komórka z =WEBSERVICE(AUDIT_TOKEN()) uruchamia więc najpierw AUDIT_TOKEN, i jeśli aplikacja gospodarza obsługuje AUDIT_TOKEN przez handler funkcji zdefiniowanych przez użytkownika, ten handler zdąży już zapisać swoją linijkę loga, podbić licznik albo otworzyć połączenie, zanim cokolwiek spojrzy na słowo WEBSERVICE. Blocklista wewnątrz handlera to za późno. Za późno jest też wtedy, gdy ktoś ogląda zewnętrzną nazwę po tym, jak argumenty wrócą. Odmowa musi być rozstrzygnięta na zewnętrznej nazwie, zanim dotknie się jakiegokolwiek argumentu i zanim jakikolwiek kod napisany przez hosta dostanie swoją kolejkę

Sytuacja, w której to gryzie, jest prozaiczna. Serwis przyjmuje wgrane pliki XLS albo XLSX, przelicza je na serwerze i odczytuje z powrotem parę sum. Lata temu ktoś zarejestrował handler funkcji użytkownika dla dwóch funkcji biznesowych, a po drodze wyrósł w nim catch-all przekazujący każdy nierozpoznany numer do tabeli pluginów. Nikt w zespole nigdy nie wpisał =WEBSERVICE(...) do komórki. Wgrał to ktoś z zewnątrz. Utrzymanie tej formuły nietkniętej przez otwarcie, przeliczenie i zapis to robota za wierność pliku; dopuszczenie jej do kodu hosta, który potrafi otwierać sockety albo pisać pliki, to decyzja autoryzacyjna — a te dwie rzeczy potrzebują różnych właścicieli

Jak zachowana formuła dociera do twoich callbacków Delphi

W silniku formuł HotXLS każde wywołanie, którego kalkulator nie umie policzyć natywnie, wpada do jednej wspólnej ścieżki dispatchu, a niebezpieczne nazwy przychodzą tam dwiema drogami. Jedna to wbudowane funkcje, które parser rozpoznaje, ale kalkulator nie ma dla nich implementacji: CALL i REGISTER.ID to prawdziwe nazwy funkcji Excela, więc parsują się czysto, po czym lądują w tym samym fallbacku co twój DISCOUNT albo REGIONRATE. Druga to nazwy, których parser w ogóle nie zna, jak WEBSERVICE albo DDE, które mogą trafić na wpis o tej samej nazwie w rejestrze albo zostać przechwycone przez handler zdarzenia catch-all. Mechanikę tego fallbacku opisuje tekst o tym, jak HotXLS rozwiązuje funkcje własne przez OnUserFunction

Kolejność łańcucha rozstrzyga, co znaczy „nieznane". Nazwa trafia najpierw do machinerii LAMBDA: domknięcie związane przez LET albo zdefiniowana nazwa, której formułą jest LAMBDA — oba opisane w obsłudze domknięć w silniku formuł HotXLS. Po niej przychodzą funkcje lokalne skoroszytu zarejestrowane przez RegisterUserFunction, dalej funkcje całego procesu z metody klasowej TXLSWorkbook.RegisterGlobalUserFunction, a na końcu zdarzenia. OnUserFunctionEx chodzi, gdy jest przypisane; OnUserFunction chodzi tylko, gdy OnUserFunctionEx nie jest. Naprawdę nieznana nazwa staje się #NAME?, gdy każdy etap odmówi, a rozpoznany, lecz niezaimplementowany wbudowany raportuje funkcję niewspieraną

Każdy etap po wyszukaniu LAMBDA przekazuje sterowanie kodowi, który napisałeś. Dlatego HotXLS stawia swoje sprawdzenie bezpieczeństwa przed każdym etapem, który dociera do twojego kodu, a nie wewnątrz któregokolwiek z nich, i dlatego sprawdzenie siedzi w samym kalkulatorze, a nie w trzech czytnikach plików. Formuła wczytana z tokenów Classic BIFF, z tekstu XLSX albo z formuły msoxl: w ODS kończy jako to samo sparsowane wyrażenie, więc jedno sprawdzenie pokrywa wszystkie trzy formaty, a skonwertowany skoroszyt zachowuje się tak samo jak oryginał

Jak HotXLS blokuje niebezpieczne callbacki formuł: nazwa funkcji jest sprawdzana względem zestawu zakazów, zanim zostanie zbudowana tablica argumentów albo ruszy jakikolwiek resolver, więc zagnieżdżone =WEBSERVICE(AUDIT_TOKEN()) zostaje odrzucone z xlfeUnsafeFunctionDenied, a log audytu zostaje pusty, podczas gdy bezpieczne wywołania przechodzą łańcuch od wiązań LAMBDA i LET aż po OnUserFunction
Dopiero gdy każdy etap odmówi, naprawdę nieznana funkcja staje się #NAME?, dlatego sprawdzenie bezpieczeństwa siedzi przed całym łańcuchem, a nie wewnątrz któregokolwiek handlera

Które nazwy formuł HotXLS odrzuca domyślnie?

HotXLS odrzuca stały zestaw 20 nazw, chyba że callbacki zostały jawnie dozwolone: 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 i FILE.DELETE. W Excelu albo w jego języku makr to nazwy, które ładują kod natywny, sięgają sieci, rozmawiają z innymi procesami albo dotykają systemu plików. Przed porównaniem silnik ucina białe znaki z brzegów, podnosi nazwę do wielkich liter i zdejmuje pojedynczy prefiks _XLFN. albo _XLWS.. Plik zapisany przez nowszą buildę Excela, który trzyma _xlfn.webservice, zostaje złapany dokładnie tak samo jak goła pisownia

Jak HotXLS normalizuje nazwę funkcji przed porównaniem z listą niebezpiecznych callbacków: białe znaki są ucinane, nazwa podnoszona do wielkich liter, a pojedynczy prefiks _XLFN. albo _XLWS. zdejmowany, więc _xlfn.webservice zostaje złapany jak goła pisownia, po czym wynik jest dokładnie dopasowywany do stałego zestawu 20 zakazanych nazw w lxCalc.pas
Lista obejmuje nazwy ładujące kod natywny, sięgające sieci, rozmawiające z innymi procesami albo dotykające systemu plików — od DDE, CALL i WEBSERVICE po FWRITE i FILE.DELETE

Dopasowanie jest dokładne, co tnie w obie strony. Handler zarejestrowany jako MYWEBSERVICE nie jest dotknięty. Firmowa UDF, która akurat nazywa się OPEN albo RUN, jest domyślnie odrzucana, a kusząca naprawa to ustawić AllowUnsafeFormulaCallbacks na True na skoroszycie, żeby ta jedna nazwa znów działała. Zamiast tego zmień nazwę funkcji. Włączenie całego skoroszytu, by uratować jedną firmową nazwę, włącza z powrotem CALL, DDE i FILE.DELETE dla każdej formuły w tym pliku, łącznie z formułami, których ty nie napisałeś

Zestaw zakazów nie ogrodzi też twoich własnych handlerów. Jeśli gałąź catch-all wykonuje dowolne nazwy pluginów, bramka zatrzyma 20 dobrze znanych niebezpiecznych i przepuści wszystko inne do twojego kodu. Trwała odpowiedź to przestać pisać handlery catch-all. Rejestracja każdej funkcji po nazwie przez RegisterUserFunction zamienia sam rejestr w listę dozwolonych i trzyma sprawdzenie wejścia formuły sensownym: gdy OnUserFunction albo OnUserFunctionEx jest przypisane, silnik nie umie wiedzieć, które nazwy zdarzenie sobie przechwyci, więc walidacja wejścia przestaje flagować nieznane nazwy funkcji w ogóle

procedure TImportService.WireFunctions;
begin
  // Jedna rejestracja na nazwę: rejestr jest listą dozwolonych
  FBook.RegisterUserFunction('DISCOUNT', HandleDiscount);
  FBook.RegisterUserFunction('REGIONRATE', HandleRegionRate);
  // Zostaw oba zdarzenia nieprzypisane, żeby nieznane nazwy wciąż
  // były odrzucane na wejściu i kończyły jako #NAME? przy ewaluacji
  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 to wariantowa tablica indeksowana od zera z już policzonych argumentów
  if VarIsArray(Args) and (VarArrayHighBound(Args, 1) = 1) then
  begin
    Value := Args[0] * (1 - Args[1]);
    Handled := True;
  end;
end;

Dlaczego bramka musi ruszać przed ewaluacją argumentów?

HotXLS sprawdza najpierw nazwę funkcji i odmawia wywołania, zanim zbuduje tablicę argumentów, zanim sięgnie do rezolwera albo do któregokolwiek rejestru, i nawet zanim zauważy, że żaden handler w ogóle nie jest przypisany. Ta kolejność rozbraja zagnieżdżoną formułę z otwarcia artykułu: zewnętrzny WEBSERVICE zostaje odrzucony, póki AUDIT_TOKEN jest wciąż niepoliczonym węzłem dziecka, więc handler hosta nigdy nie chodzi. Ostatnia część kolejności ma znaczenie dla logowania. Skoroszyt bez handlerów i skoroszyt z pięcioma raportują ten sam status dla =WEBSERVICE(...), więc log odrzuceń nie zmienia znaczenia, gdy ktoś później podepnie 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');   // skutek uboczny w kodzie hosta
    Value := 'token-42';
    Handled := True;
  end;
end;

procedure TImportService.ProbeNestedCall;
var
  Sheet: TXLSWorksheet;
  Eval: TXLSFormulaEvaluationResult;
begin
  FBook.OnUserFunction := HandleUdf;
  Sheet := FBook.Sheets[1];                  // indeksy arkuszy startują od 1
  Eval := Sheet.EvaluateFormulaAt(1, 1, '=WEBSERVICE(AUDIT_TOKEN())');
  // Oczekiwane: Eval.Status = xlfeUnsafeFunctionDenied, Eval.Value = Null,
  // Eval.Issue.NativeCode = -106 oraz FAuditLog.Count = 0
end;

Kod natywny -106 to publiczna stała lxErrorUnsafeFunctionDenied, a Eval.Issue.Message brzmi „The formula callback is blocked by the safety policy". xlfeUnsafeFunctionDenied jest ostatnim członem TXLSFormulaEvaluationStatus, za xlfeFailed. Kod mapujący statusy przez case z gałęzią else dla ogólnej porażki wpisałby odmowę pod „nieudana ewaluacja", chyba że jawnie nazwie status, a dla potoku wgrywania to właśnie ten wynik wart jest odesłania wgrywającemu

Domyślne ustawienie skoroszytu kontra per-wywołaniowe TXLSFormulaEvaluationOptions

Flaga skoroszytu ustawia domyślne, a opcja per wywołanie ma ostatnie słowo. TXLSWorkbook.AllowUnsafeFormulaCallbacks i TXLSXWorkbook.AllowUnsafeFormulaCallbacks startują jako False i rządzą zwykłym przeliczaniem, Calculate, trójargumentowym EvaluateFormulaAt, EvaluateAt na szablonie ewaluacji, formułami list walidacji danych, widokami tylko do odczytu i — po stronie XLSX — każdym workerem puli równoległego przeliczania. Każdy punkt wejścia przyjmujący jawny rekord TXLSFormulaEvaluationOptions bierze Options.AllowUnsafeFormulaCallbacks jako werdykt dla tego wywołania i nie skleja go operatorem OR z właściwością skoroszytu

Ta asymetria jest celowa i to jest właściwy domyślny. Zaufane wewnętrzne zadanie może zautoryzować jedno wyszukanie RTD bez przekręcania całego skoroszytu, a skoroszyt globalnie włączony potrafi mimo to wymusić powrót wrażliwej ewaluacji na odrzucenie. XLSDefaultFormulaEvaluationOptions zwraca flagę jako False, więc zbudowanie rekordu z tej funkcji znaczy odrzucenie, chyba że ustawisz ją celowo. Jedna ścieżka ignoruje flagę całkowicie: gdy reguły warunkowego formatowania Classic są liczone dla renderowania, callbacki są odrzucane, cokolwiek mówi skoroszyt

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

  // skoroszyt zostaje zablokowany, jedno zaufane wywołanie przechodzi
  FBook.AllowUnsafeFormulaCallbacks := False;
  Options := XLSDefaultFormulaEvaluationOptions;
  Options.AllowUnsafeFormulaCallbacks := True;
  Eval := Sheet.EvaluateFormulaAt(4, 2, '=WEBSERVICE(B1)', xlfrsA1, Options);

  // skoroszyt włączony, ale ta ewaluacja wgranego tekstu już nie
  FBook.AllowUnsafeFormulaCallbacks := True;
  Options := XLSDefaultFormulaEvaluationOptions;   // flaga znów ma False
  Eval := Sheet.EvaluateFormulaAt(4, 2, UploadedFormula, xlfrsA1, Options);
  if Eval.Status = xlfeUnsafeFunctionDenied then
    LogRejected(Eval.Issue.Message);   // LogRejected to twoja własna rutyna
end;

Zmiana właściwości skoroszytu oznacza też przy okazji każdą formułę w grafie zależności jako brudną, na obu klasach skoroszytów. Bez tego wartość zcache'owana w czas, gdy callbacki były dozwolone, mogłaby zostać podana już po ich cofnięciu, albo zcache'owana odmowa mogłaby przeżyć opt-in. Ustawienie właściwości na wartość, którą już trzyma, jest no-opem i zostawia cache w spokoju, więc przełączanie jej w pętli kosztuje pełne przeliczenie tylko, gdy wartość faktycznie się zmienia

Co się dzieje z nieznanym i niebezpiecznym tekstem formuły przy zapisie?

Trzymanie formuły i jej uruchamianie to dwa osobne pytania, a polityka wejścia odpowiada tylko na pierwsze. FormulaEntryPolicy na każdej z klas skoroszytów niesie UnknownFunctionMode i UnknownNameMode, oba domyślnie xlfusmReject. Przypisanie formuły z nieznanym wywołaniem przez zwykłą właściwość Formula podnosi EArgumentException z komunikatem „Formula entry rejected: unknown function", zanim zmienią się wartość komórki, cache formuły albo zależności. ValidateFormulaEntry zwraca tę samą decyzję jako rekord, bez skutków ubocznych. Zaufane ścieżki, jak wczytywanie pliku, kopiowanie i konwersja formatów, nie przechodzą przez politykę wejścia użytkownika, bo surowy domyślny nie może nigdy odrzucić symboli już obecnych w pliku, który po prostu otwierasz

var
  Sheet: TXLSWorksheet;
  Policy: TXLSFormulaEntryPolicy;
  Check: TXLSFormulaEntryValidationResult;
begin
  Sheet := FBook.Sheets[1];
  Check := Sheet.ValidateFormulaEntry(3, 1, '=ACME_RATE(B3)');
  // domyślna polityka, brak handlera dla ACME_RATE:
  // Check.Issue = xlfeiUnknownFunction, Check.Action = xlfeaReject

  Policy := FBook.FormulaEntryPolicy;
  Policy.UnknownFunctionMode := xlfusmPreserve;   // wejście dla zgodności
  FBook.FormulaEntryPolicy := Policy;
  Sheet.Cells[3, 1].Formula := '=ACME_RATE(B3)';  // zapisana, nieautoryzowana
  FBook.SaveAs('rates.xls');
end;

Klasyczny BIFF8 nie ma tokenu na dowolną nazwę funkcji, więc HotXLS zapisuje nieznane wywołanie tak, jak Excel zapisuje funkcje dodatków. Formuła dostaje token PtgNameX ($59), którego wpis XTI wskazuje na dodatkowy SUPBOOK z oboma indeksami arkuszy ustawionymi na $FFFE, po nim idą tokeny argumentów i PtgFuncVar z numerem funkcji 255 i liczbą argumentów uwzględniającą slot nazwy. Wspierające ciało ExternName to sześć zerowych bajtów, bajt długości i flaga Unicode, nazwa funkcji w UTF-16, a potem dwubajtowa formuła $1C $17, czyli PtgErr niosący #REF!. Zapisujący przyjmuje nazwy od 1 do 255 znaków z najwyżej 29 argumentami i celuje wyłącznie w BIFF8; zapis BIFF5 nie uniesie tego wywołania. Jak te wpisy dodatkowych SUPBOOK mieszczą się obok zewnętrznych łączy do skoroszytów, wyjaśnia tekst o regułach klasyfikacji SUPBOOK i XTI dla zewnętrznych łączy BIFF

Jak HotXLS zapisuje nieznane wywołanie formuły w klasycznym BIFF8: formuła niesie token PtgNameX, którego wpis XTI wskazuje na dodatkowy SUPBOOK z oboma indeksami arkuszy $FFFE, potem tokeny argumentów i PtgFuncVar z numerem funkcji 255, wsparte ciałem ExternName kończącym się dwubajtowym $1C $17 PtgErr niosącym #REF!
XLSX trzyma surowy tekst funkcji, a ODS swoją formułę msoxl:, więc plik, który zapisał =WEBSERVICE(...), otwiera się ponownie z nietkniętym tekstem i domyślnie dalej ewaluowany jest na xlfeUnsafeFunctionDenied

XLSX trzyma surowy tekst funkcji, a ODS swoją formułę msoxl:. We wszystkich trzech formatach plik, który zapisał =WEBSERVICE(...), otwiera się ponownie z nietkniętą nazwą, a jego domyślna ewaluacja nadal zwraca xlfeUnsafeFunctionDenied bez wołania hosta. Zapis nigdy nie podnosi uprawnień formuły: ten sam tekst, który był odrzucony przed rundą otwórz-zapisz, jest odrzucony po niej

Dla serwera przeliczającego skoroszyty, których sam nie napisał, konfiguracja, która się broni, jest krótka: zostaw AllowUnsafeFormulaCallbacks na False, rejestruj funkcje po nazwie zamiast przypisywać zdarzenie catch-all i podawaj opcje per wywołanie tylko tam, gdzie tekst formuły pochodzi z twojego kodu. Dokumentacja callbacków, polityki wejścia i ewaluacji przychodzi w komplecie z komponentem arkuszowym HotXLS dla Delphi