Техническая статья

Опасные колбэки формул HotXLS: гейт для CALL и WEBSERVICE

HotXLS отказывается маршрутизировать 20 опасных имён формул, включая CALL, REGISTER.ID, WEBSERVICE и DDE, в ваши Delphi-колбэки пользовательских функций, пока вы явно не разрешите. Свойство книги AllowUnsafeFormulaCallbacks по умолчанию False, проверка выполняется до вычисления любого аргумента, а отклонённый вызов репортит xlfeUnsafeFunctionDenied, не вызвав ни одного обработчика

Сценарий, сделавший это необходимым, донельзя заурядный. Сервис принимает загруженные XLS или XLSX файлы, пересчитывает их на сервере и читает несколько итогов обратно. Хост-приложение года назад зарегистрировало обработчик OnUserFunction для пары бизнес-функций, и где-то по пути обработчик оброс catch-all-веткой, пересылающей всё, что она не опознаёт, в таблицу плагинов. Никто в команде ни разу не вводил =WEBSERVICE(...) в ячейку. А uploader ввёл. Сохранить эту формулу нетронутой через открытие, пересчёт и сохранение — фича точности файла. Пустить её в хост-код, способный открывать сокеты или файлы, — решение об авторизации, и до тех пор, пока HotXLS не разделил эти две вещи, библиотека молча принимала это решение за вас

Как сохранение формулы превратилось в разрешение её исполнять?

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

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

Как HotXLS гейтит небезопасные колбэки формул: 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 нормализует имя функции перед сравнением на небезопасный колбэк: пробелы обрезаются, имя приводится к верхнему регистру, одиночный префикс _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 иначе сработала бы первой и оставила бы свой сайд-эффект

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');   // сайд-эффект в хост-коде
    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, шаблонами вычисления, read-only представлениями и, на XLSX, каждым воркером пула параллельного пересчёта. Любая входная точка, принимающая явную запись TXLSFormulaEvaluationOptions, берёт Options.AllowUnsafeFormulaCallbacks как вердикт для этого вызова и не OR-ит его со свойством книги. Эта асимметрия намеренная: доверенная внутренняя задача может авторизовать один RTD-lookup, не переключая всю книгу, а глобально разрешившая себя книга всё ещё может заставить чувствительное вычисление снова стать deny

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;

Переключение свойства книги также помечает граф зависимостей грязным на обоих движках. Без этого шага закэшированный результат, вычисленный, пока колбэки были разрешены, мог бы отдаваться после их отзыва, или закэшированный исход xlfeUnsafeFunctionDenied мог бы пережить разрешение. Новый статус добавлен в конец TXLSFormulaEvaluationStatus после xlfeFailed, так что у него ordinal 10 и каждый существующий ordinal сохраняет значение; то же правило хвостового добавления относится к полю записи опций и к геттеру и сеттеру IXLSWorkbook, хотя собранному против старого релиза потребителю всё ещё нужна перекомпиляция

Что происходит с неизвестным и небезопасным текстом формулы при сохранении?

Сохранить формулу и исполнить её — теперь два отдельных вопроса, и политика входа отвечает только на первый. FormulaEntryPolicy на любом из классов книг несёт UnknownFunctionMode и UnknownNameMode, оба по умолчанию xlfusmReject, так что присваивание формулы с неизвестным вызовом через обычное свойство Formula отклоняется до того, как изменится значение ячейки, кэш формулы или зависимости. ValidateFormulaEntry репортит то же решение без сайд-эффектов. Доверенные пути вроде загрузки файла, копирования и конвертации формата обходят эту политику пользовательского ввода, потому что строгий дефолт никогда не должен отвергать символы, уже присутствующие в файле, который вы просто открываете

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 пишет аддин-функции. Формула получает токен PtgNameX ($59), чья XTI-запись указывает на аддиновский SUPBOOK с обоими индексами листов, выставленными в $FFFE, за которым следуют токены аргументов и PtgFuncVar с номером функции 255 и счётчиком аргументов, включающим слот имени. Тело-подложка ExternName — шесть нулевых байтов, байт длины и флаг Unicode, имя функции в UTF-16, затем двухбайтовая формула из $1C $17, PtgErr с #REF!. Райтер отказывает именам длиннее 255 символов, числу аргументов больше 29 и цели BIFF5. Как HotXLS классифицирует эти аддиновые записи SUPBOOK рядом со ссылками на внешние книги, объясняется в статье про правила классификации SUPBOOK и XTI для внешних ссылок BIFF. XLSX хранит сырой текст функции, ODS хранит свою формулу msoxl:, и в каждом формате файл, сохранивший =WEBSERVICE(...), переоткрывается с текстом нетронутым и по умолчанию всё ещё вычисляется в xlfeUnsafeFunctionDenied

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

Если ваш конвейер вычисляет книги, которых он не писал, держите AllowUnsafeFormulaCallbacks в False, держите обработчики на явном allowlist и выдавайте опции на вызов только там, где источник формулы — ваш. Полный API колбэков, политики входа и вычисления документирован вместе с компонентом электронных таблиц HotXLS для Delphi