Artykuł techniczny

Callbacki formuł HotXLS: blokada CALL i WEBSERVICE

HotXLS odmawia przekazywania 20 niebezpiecznych nazw formuł, w tym CALL, REGISTER.ID, WEBSERVICE i DDE, do twoich callbacków funkcji użytkownika w Delphi, dopóki sam nie wyrazisz na to zgody. Właściwość skoroszytu AllowUnsafeFormulaCallbacks ma domyślnie False, sprawdzenie biegnie przed ewaluacją jakiegokolwiek argumentu, a odrzucone wywołanie raportuje xlfeUnsafeFunctionDenied, nie wywoławszy żadnego handlera

Scenariusz, który tego wymusił, jest prozaiczny. Serwis przyjmuje wgrane pliki XLS albo XLSX, przelicza je po stronie serwera i odczytuje z powrotem parę sum. Aplikacja gospodarza zarejestrowała handler OnUserFunction lata temu dla pary funkcji biznesowych, a gdzieś po drodze handler wyrósł sobie z gałęzi catch-all, która przekazuje wszystko, czego nie rozpoznaje, 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 funkcja wierności pliku. Dopuszczenie jej do kodu hosta, który potrafi otwierać sockety albo pliki, to decyzja autoryzacyjna — a dopóki HotXLS nie rozdzielił tych dwóch rzeczy, biblioteka cicho podejmowała ją za ciebie

Dlaczego zachowanie formuły zamieniło się w pozwolenie na jej uruchomienie?

Pierwszą przyczyną była pojedyncza ścieżka awaryjna. HotXLS parsuje każdą nazwę funkcji Excela, którą zna, ale nie każda znana nazwa ma implementację w silniku obliczeń. Wbudowane rozpoznane, lecz niezaimplementowane, wpadały dawniej do tego samego fallbacku funkcji zdefiniowanych przez użytkownika co naprawdę własne nazwy, więc CALL i REGISTER.ID dzieliły ścieżkę dispatchu z twoim DISCOUNT albo REGIONRATE. Nieznane nazwy jak WEBSERVICE czy DDE mogły z kolei trafić na wpis o tej samej nazwie w rejestrze skoroszytu, rejestrze całego procesu albo handlerze zdarzenia. Mechanikę tego fallbacku opisuje tekst o tym, jak HotXLS rozwiązuje funkcje własne przez OnUserFunction; problem w tym, że nic na tej ścieżce nie pytało, czy nazwa sama w sobie to coś, co przyzwoity host powinien kiedykolwiek wykonać

Kolejność dispatchu ma znaczenie dla tego, co „nieznane” znaczy w tym miejscu. Wywołanie, którego silnik nie umie policzyć natywnie, trafia po kolei do leksykalnych wiązań LAMBDA i LET, które najpierw rozwiązuje obsługa domknięć w silniku formuł HotXLS, potem do funkcji lokalnych skoroszytu zarejestrowanych przez RegisterUserFunction, dalej do funkcji całego procesu z TXLSWorkbook.RegisterGlobalUserFunction, a na końcu do zdarzeń OnUserFunction i OnUserFunctionEx. Dopiero gdy wszystkie odmówią, naprawdę nieznana funkcja staje się #NAME?. Każdy etap po wyszukaniu lambdy przekazuje sterowanie kodowi, który napisałeś — i właśnie dlatego sprawdzenie bezpieczeństwa musi siedzieć przed całym łańcuchem, a nie wewnątrz któregokolwiek handlera

Jak HotXLS blokuje niebezpieczne callbacki formuł: GetValueItemUserFunction sprawdza nazwę, zanim zostanie zbudowana tablica argumentów albo ruszy jakikolwiek resolver, więc zagnieżdżone =WEBSERVICE(AUDIT_TOKEN()) kończy się lxErrorUnsafeFunctionDenied, 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 funkcji HotXLS blokuje domyślnie?

XLSFormulaCallbackIsUnsafe w lxCalc.pas trzyma stały zestaw 20 zakazanych nazw: 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. To nazwy, które w Excelu albo w jego języku makr ładują kod natywny, sięgają sieci, rozmawiają z innymi procesami albo dotykają systemu plików. Przed porównaniem funkcja ucina białe znaki z brzegów, podnosi nazwę do wielkich liter i zdejmuje pojedynczy prefiks _XLFN. albo _XLWS., więc _xlfn.webservice zapisany przez nowszą buildę Excela zostaje złapany tak samo jak goła pisownia. Lista mieszka na granicy kalkulatora, a nie w parserach Classic, XLSX i ODS, dzięki czemu jeden AST, jeden strumień tokenów BIFF i jeden skonwertowany skoroszyt zachowują się identycznie

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

Dwa brzegi warto znać, zanim na tym polegniesz. Dopasowanie jest dokładne, więc handler zarejestrowany jako MYWEBSERVICE nie jest dotknięty, a odwrotnie — legalna firmowa UDF, która akurat nazywa się OPEN albo RUN, jest od teraz domyślnie odrzucana. Zestaw zakazów to też nie sandbox dla twoich własnych handlerów. Jeśli twoja gałąź catch-all wykonuje dowolne nazwy pluginów, bramka zatrzyma słynne niebezpieczne i nic poza tym; trwała poprawka to nadal handler, który dopasowuje do jawnej listy dozwolonych przez SameText i zostawia Handled na False dla wszystkiego, czego nie jest właścicielem

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

Bramka, która odpala po policzeniu argumentów, jest za późno, bo argumenty same potrafią wywołać twój kod. GetValueItemUserFunction najpierw sprawdza nazwę i wychodzi z lxErrorUnsafeFunctionDenied, 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 podpięty. Ta kolejność rozbraja zagnieżdżony przypadek z przykładu poniżej: zewnętrzne wywołanie i tak zostałoby odrzucone, ale niewinnie wyglądająca wewnętrzna UDF odpaliłaby pierwsza i zostawiła po sobie skutek uboczny

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;

Book.OnUserFunction := HandleUdf;
Eval := Sheet.EvaluateFormulaAt(1, 1, '=WEBSERVICE(AUDIT_TOKEN())');
// Eval.Status = xlfeUnsafeFunctionDenied, Eval.Value = Null,
// Eval.Issue.NativeCode = -106, a FAuditLog wciąż jest pusty

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

Flaga skoroszytu jest domyślna, a opcja per wywołanie ma ostatnie słowo. TXLSWorkbook.AllowUnsafeFormulaCallbacks i TXLSXWorkbook.AllowUnsafeFormulaCallbacks rządzą zwykłym przeliczaniem, Calculate, dwuargumentowym EvaluateFormulaAt, szablonami ewaluacji, 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: 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ć cofnięcie wrażliwej ewaluacji na odrzucenie

var
  Options: TXLSFormulaEvaluationOptions;
  Eval: TXLSFormulaEvaluationResult;
begin
  // skoroszyt zostaje zablokowany, jedno zaufane wywołanie przechodzi
  Book.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
  Book.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);
end;

Przełączenie właściwości skoroszytu oznacza przy okazji graf zależności jako brudny w obu silnikach. Bez tego kroku wynik z cache policzony, gdy callbacki były dozwolone, mógłby zostać podany już po ich cofnięciu, albo zapisany w cache wynik xlfeUnsafeFunctionDenied mógłby przeżyć opt-in. Nowy status został doklejony do TXLSFormulaEvaluationStatus za xlfeFailed, więc ma ordinal 10 i każdy istniejący ordinal zachowuje swoją wartość; ta sama reguła doklejania na ogon obowiązuje pole rekordu opcji oraz getter i setter IXLSWorkbook, choć konsument zbudowany przeciw starszej wersji i tak potrzebuje rekompilacji

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

Trzymanie formuły i jej uruchamianie to od teraz dwa osobne pytania, a polityka wejścia odpowiada tylko na pierwsze. FormulaEntryPolicy na obu klasach skoroszytów niesie UnknownFunctionMode i UnknownNameMode, oba domyślnie xlfusmReject, więc przypisanie formuły z nieznanym wywołaniem przez zwykłą właściwość Formula zostaje odrzucone, zanim zmienią się wartość komórki, cache formuły albo zależności. ValidateFormulaEntry raportuje tę samą decyzję bez skutków ubocznych. Zaufane ścieżki, jak wczytywanie plików, kopiowanie i konwersja formatów, omijają tę politykę wejścia użytkownika, bo surowy domyślny stan nie może nigdy odrzucić symboli już obecnych w pliku, który po prostu otwierasz

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

W klasycznym BIFF8 nieznane wywołanie nie ma własnego tokenu, więc HotXLS zapisuje je 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 odmawia nazw dłuższych niż 255 znaków, więcej niż 29 argumentów i celu BIFF5. Jak HotXLS klasyfikuje te wpisy dodatkowych SUPBOOK obok zewnętrznych łączy do skoroszytów, wyjaśnia tekst o regułach klasyfikacji SUPBOOK i XTI dla zewnętrznych łączy BIFF. XLSX trzyma surowy tekst funkcji, ODS trzyma swoją formułę msoxl:, a w każdym formacie plik, który zapisał =WEBSERVICE(...), otwiera się ponownie z nietkniętym tekstem i domyślnie dalej ewaluowany jest na xlfeUnsafeFunctionDenied

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

Jeśli twój potok ewaluowałby skoroszyty, których sam nie napisał, zostaw AllowUnsafeFormulaCallbacks na False, trzymaj handlery na jawnej liście dozwolonych i przyznawaj opcje per wywołanie tylko tam, gdzie źródło formuły jest twoje. Pełne API callbacków, polityki wejścia i ewaluacji jest udokumentowane przy komponencie arkuszowym HotXLS dla Delphi