Технічна стаття

Небезпечні callback-и: фільтр CALL/WEBSERVICE у HotXLS

HotXLS відмовляється маршрутити 20 небезпечних імен формул, серед них CALL, REGISTER.ID, WEBSERVICE і DDE, до ваших Delphi callback-ів користувацьких функцій, якщо ви самі цього не дозволите. Властивість книги AllowUnsafeFormulaCallbacks типово False, перевірка йде до того, як хоч один аргумент буде обчислено, а відхилений виклик повідомляє xlfeUnsafeFunctionDenied, не викликавши жодного обробника

Сценарій, який усе це змусив, буденний. Сервіс приймає завантажені файли XLS чи XLSX, перераховує їх на сервері і читає кілька підсумків назад. Хост-застосунок роками тому зареєстрував обробник OnUserFunction для пари бізнес-функцій, і десь по дорозі той обробник обзавівся catch-all гілкою, яка пересилає все, чого не розпізнає, у таблицю плагінів. Ніхто в команді ніколи не друкував =WEBSERVICE(...) у клітинку. Це зробив завантажувач файлів. Тримати ту формулу недоторканою крізь відкриття, перерахунок і збереження — це фіча вірності файлу. Давати їй дістати хост-код, який уміє відкривати сокети чи файли, — це рішення про авторизацію, і доти, доки HotXLS не розрізнив ці дві речі, бібліотека мовчки ухвалювала його за вас

Як збереження формули перетворилося на дозвіл її запускати?

Корінь зла — один fallback-шлях. HotXLS парсить кожне ім'я функції Excel, яке знає, але не за кожним відомим іменем стоїть реалізація в обчислювальному рушії. Вбудовані, які розпізнано, але не реалізовано, колись падали в той самий fallback користувацьких функцій, що й справді кастомні імена, тож CALL і REGISTER.ID ділили диспетчерський шлях з вашим DISCOUNT чи REGIONRATE. Невідомі імена на кшталт WEBSERVICE чи DDE так само могли влучити в однойменний запис у реєстрі книги, загальнопроцесовому реєстрі чи обробнику подій. Механіку того fallback-у описує стаття про те, як HotXLS розв'язує кастомні функції через OnUserFunction; проблема в тому, що ніщо на тому шляху не питало, чи не є це ім'я тим, що його здоровий хост узагалі не має виконувати

Порядок диспетчеризації важливий для того, що тут означає «невідомо». Виклик, який рушій не може обчислити нативно, по черзі пропонується лексичним прив'язкам LAMBDA і LET, які підтримка замикань у формульному рушії HotXLS розв'язує першою, потім функціям, зареєстрованим у книзі через RegisterUserFunction, потім загальнопроцесовим функціям з TXLSWorkbook.RegisterGlobalUserFunction і нарешті подіям OnUserFunction і OnUserFunctionEx. Лише коли всі вони відмовляються, справді невідома функція стає #NAME?. Кожна стадія після lookup-а лямбд віддає керування коду, який написали ви, — і саме тому перевірка безпеки мусить сидіти перед усім ланцюгом, а не всередині якогось одного обробника

Як HotXLS фільтрує небезпечні callback-и формул: GetValueItemUserFunction перевіряє ім'я до того, як буде збудовано масив аргументів чи запущено якийсь резолвер, тож вкладений =WEBSERVICE(AUDIT_TOKEN()) виходить з lxErrorUnsafeFunctionDenied і журнал аудиту лишається порожнім, тоді як безпечні виклики йдуть ланцюгом від прив'язок LAMBDA і LET вниз до OnUserFunction
Лише коли всі стадії відмовляються, справді невідома функція стає #NAME?, і саме тому перевірка безпеки сидить перед усім ланцюгом, а не всередині якогось одного обробника

Які імена функцій HotXLS блокує типово?

XLSFormulaCallbackIsUnsafe у lxCalc.pas тримає фіксований deny-набір із 20 імен: 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 і FILE.DELETE. Це імена, які в Excel чи його макромові вантажать нативний код, дістаються до мережі, говорять з іншими процесами чи чіпають файлову систему. Перед порівнянням функція обрізає обвідні пробіли, підводить ім'я до верхнього регістру і здирає один префікс _XLFN. чи _XLWS., тож _xlfn.webservice, написаний новішою збіркою Excel, ловиться так само, як голе написання. Список живе на межі калькулятора, а не в парсерах Classic, XLSX і ODS, що тримає один AST, один потік токенів BIFF і одну сконвертовану книгу однаковими у поведінці

Як HotXLS нормалізує ім'я функції перед порівнянням з unsafe-callback набором: пробіли обрізано, ім'я підведено до верхнього регістру, а один префікс _XLFN. чи _XLWS. зірвано, тож _xlfn.webservice ловиться як голе написання, після чого результат точно зіставляється з фіксованим deny-набором із 20 імен у lxCalc.pas
Список покриває імена, які вантажать нативний код, дістаються до мережі, говорять з іншими процесами чи чіпають файлову систему — від DDE, CALL і WEBSERVICE до FWRITE і FILE.DELETE

Дві крайнощі варто знати, перш ніж на це покладатися. Збіг точний, тож обробник, який ви зареєстрували як MYWEBSERVICE, не зачеплений, і навпаки — легітимна власна UDF, яка випадково зветься OPEN чи RUN, тепер типово відхиляється. Deny-набір — це також не пісочниця для ваших обробників. Якщо ваша catch-all гілка виконує довільні імена плагінів, гейт зупиняє лише знамениті небезпечні імена і нічого крім них; довговічне виправлення — обробник, який звіряє з явним allowlist через SameText і лишає Handled у False для всього, чим не володіє

Чому гейт мусить працювати до обчислення аргументів?

Гейт, який стріляє після обчислення аргументів, запізнілий, бо самі аргументи можуть викликати ваш код. GetValueItemUserFunction перевіряє ім'я першим і виходить з lxErrorUnsafeFunctionDenied до того, як побудує масив аргументів, до того, як загляне до резолвера чи будь-якого реєстру, і навіть до того, як помітить, що жоден обробник узагалі не призначений. Саме цей порядок ламає вкладений випадок нижче, де зовнішній виклик і так відхилили б, але нешкідливий на вигляд внутрішній UDF інакше спрацював би першим і лишив би свій side effect

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');   // side effect у хост-коді
    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, а FAuditLog досі порожній

Типове для книги проти по-викликового TXLSFormulaEvaluationOptions

Прапорець книги — типове значення, а по-викликова опція — останнє слово. TXLSWorkbook.AllowUnsafeFormulaCallbacks і TXLSXWorkbook.AllowUnsafeFormulaCallbacks керують звичайним перерахунком, Calculate, двоаргументним EvaluateFormulaAt, шаблонами обчислення, переглядами лише для читання і, на XLSX, кожним робітником у пулі паралельного перерахунку. Будь-яка точка входу, що приймає явний запис TXLSFormulaEvaluationOptions, бере Options.AllowUnsafeFormulaCallbacks як вердикт для цього виклику і не OR-ить його з властивістю книги. Ця асиметрія навмисна: довірена внутрішня робота може авторизувати один lookup RTD, не перемикаючи всю книгу, а книга, глобально вписана в дозвіл, усе одно може змусити чутливе обчислення назад до відмови

var
  Options: TXLSFormulaEvaluationOptions;
  Eval: TXLSFormulaEvaluationResult;
begin
  // книга лишається під замком, один довірений виклик пропускають
  Book.AllowUnsafeFormulaCallbacks := False;
  Options := XLSDefaultFormulaEvaluationOptions;
  Options.AllowUnsafeFormulaCallbacks := True;
  Eval := Sheet.EvaluateFormulaAt(4, 2, '=WEBSERVICE(B1)', xlfrsA1, Options);

  // книга вписана в дозвіл, але це обчислення завантаженого тексту — ні
  Book.AllowUnsafeFormulaCallbacks := True;
  Options := XLSDefaultFormulaEvaluationOptions;   // прапорець знову False
  Eval := Sheet.EvaluateFormulaAt(4, 2, UploadedFormula, xlfrsA1, Options);
  if Eval.Status = xlfeUnsafeFunctionDenied then
    LogRejected(Eval.Issue.Message);
end;

Перемикання властивості книги також мічить граф залежностей брудним на обох рушіях. Без цього кроку кешований результат, обчислений, поки callback-и були дозволені, міг би подаватися після їх скасування, або кешований результат xlfeUnsafeFunctionDenied міг би пережити вписування в дозвіл. Новий статус додано в TXLSFormulaEvaluationStatus після xlfeFailed, тож він має порядковий номер 10 і всі наявні номери зберігають значення; те саме правило додавання в хвіст стосується поля запису опцій і геттера й сетера IXLSWorkbook, хоча споживач, зібраний проти старішого релізу, все одно потребує перекомпіляції

Що відбувається з невідомим і небезпечним текстом формул при збереженні?

Тримати формулу і запускати її — тепер два окремі питання, і політика входу відповідає лише на перше. FormulaEntryPolicy на будь-якому класі книги несе UnknownFunctionMode і UnknownNameMode, обидва типово xlfusmReject, тож присвоєння формули з невідомим викликом через звичайну властивість Formula відхиляється до того, як зміняться значення клітинки, кеш формул чи залежності. ValidateFormulaEntry повідомляє те саме рішення без side effect-ів. Довірені шляхи — завантаження файлу, копіювання, конвертація формату — обходять ту політику користувацького входу, бо строге типове значення ніколи не мусить відхиляти символи, які вже є у файлі, який ви просто відкриваєте

var
  Policy: TXLSFormulaEntryPolicy;
begin
  Policy := Book.FormulaEntryPolicy;
  Policy.UnknownFunctionMode := xlfusmPreserve;   // вхід для сумісності
  Book.FormulaEntryPolicy := Policy;
  Sheet.Cells[3, 1].Formula := '=ACME_RATE(B3)';  // збережено, не авторизовано
  Book.SaveAs('rates.xls');
end;

У Classic BIFF8 невідомий виклик не має власного токена, тож HotXLS пише його так, як Excel пише функції add-in-ів. Формула отримує токен PtgNameX ($59), чий XTI-запис вказує на add-in SUPBOOK з обома індексами аркушів у $FFFE, за ним ідуть токени аргументів і PtgFuncVar з номером функції 255 і кількістю аргументів, що включає слот імені. Тіло ExternName, що стоїть за ним, — це шість нульових байтів, байт довжини й прапор Unicode, ім'я функції в UTF-16, потім двобайтова формула з $1C $17, PtgErr, який тримає #REF!. Письменник відмовляє імена довші за 255 символів, понад 29 аргументів і ціль BIFF5. Як HotXLS класифікує ці add-in записи SUPBOOK поруч із зовнішніми посиланнями на книги, пояснює стаття про правила класифікації SUPBOOK і XTI для зовнішніх посилань BIFF. XLSX тримає сирий текст функції, а ODS — свою формулу msoxl:, і в кожному форматі файл, що зберіг =WEBSERVICE(...), відкривається з текстом недоторканим і все одно обчислюється в xlfeUnsafeFunctionDenied типово

Як HotXLS пише невідомий виклик формули в класичний BIFF8: формула несе токен PtgNameX, чий XTI-запис вказує на add-in SUPBOOK з обома індексами аркушів $FFFE, потім токени аргументів і PtgFuncVar з номером функції 255, а за всім цим стоїть тіло ExternName, що закінчується двобайтовим $1C $17 PtgErr з #REF!
XLSX тримає сирий текст функції, а ODS — свою формулу msoxl:, тож файл, що зберіг =WEBSERVICE(...), відкривається з текстом недоторканим і типово все одно обчислюється в xlfeUnsafeFunctionDenied

Якщо ваш pipeline обчислює книги, яких сам не створював, лишайте AllowUnsafeFormulaCallbacks у False, тримайте обробники на явному allowlist і давайте по-викликові опції лише там, де джерело формул — ваше. Повний API callback-ів, політики входу й обчислення задокументовано разом із компонентом електронних таблиць HotXLS Delphi