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

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

Табличные движки вычисляют аргументы прежде, чем вызовут принимающую их функцию. Ячейка с =WEBSERVICE(AUDIT_TOKEN()) потому сперва выполняет AUDIT_TOKEN, и если хост-приложение отвечает на AUDIT_TOKEN через обработчик пользовательских функций, этот обработчик уже записал свою строку в журнал, нарастил счётчик или открыл соединение к моменту, когда кто-то вообще взглянул на слово WEBSERVICE. Блоклист внутри обработчика опаздывает. Блоклист, осматривающий внешнее имя после возврата аргументов, тоже. Отказ надо решать по внешнему имени, до того как тронут хоть один аргумент и до того как получит ход хоть один код, написанный хостом

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

Как сохранённая формула добирается до ваших Delphi-колбэков

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

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

Каждая ступень после поиска LAMBDA передаёт управление написанному вами коду. Именно поэтому HotXLS ставит проверку безопасности перед всеми ступенями, которые достигают вашего кода, а не внутри какой-то одной из них, и почему проверка сидит в самом калькуляторе, а не в трёх читателях файлов. Формула, загруженная из Classic BIFF-токенов, из XLSX-текста или из формулы msoxl: в ODS, превращается в одно и то же разобранное выражение, так что одна проверка покрывает все три формата, и конвертированная книга ведёт себя как оригинал

Как HotXLS гейтит небезопасные колбэки формул: имя функции сверяется с deny-набором до построения массива аргументов или запуска любого резолвера, поэтому вложенный =WEBSERVICE(AUDIT_TOKEN()) отказывается с xlfeUnsafeFunctionDenied, а журнал аудита остаётся пустым, при этом безопасные вызовы проходят цепочку от биндингов LAMBDA и LET вплоть до OnUserFunction
Только когда каждый этап отказывается, по-настоящему неизвестная функция становится #NAME?, — потому проверка безопасности сидит перед всей цепочкой, а не внутри какого-то одного обработчика

Какие имена функций HotXLS блокирует по умолчанию?

HotXLS отклоняет фиксированный набор из 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.. Файл, записанный свежей сборкой Excel и хранящий _xlfn.webservice, ловится ровно как голое написание

Как HotXLS нормализует имя функции перед сравнением на небезопасный колбэк: пробелы обрезаются, имя приводится к верхнему регистру, одиночный префикс _XLFN. или _XLWS. снимается, так что _xlfn.webservice ловится как голое написание, затем результат точно сверяется с фиксированным deny-набором из 20 имён
Список покрывает имена, которые грузят нативный код, ходят в сеть, говорят с другими процессами или трогают файловую систему — от DDE, CALL и WEBSERVICE до FWRITE и FILE.DELETE

Совпадение точное, что режет в обе стороны. Обработчик, зарегистрированный как MYWEBSERVICE, не затронут. Внутренняя UDF, которой случайно досталось имя OPEN или RUN, по умолчанию отклоняется, а соблазнительный фикс — поставить AllowUnsafeFormulaCallbacks в True на книге, чтобы одно имя снова заработало. Переименуйте функцию вместо этого. Разрешение всей книги ради одного внутреннего имени заодно возвращает CALL, DDE и FILE.DELETE каждой формуле в этом файле, включая формулы, которых вы не писали

Deny-набор вдобавок ничуть не огораживает ваши собственные обработчики. Если catch-all-ветка исполняет произвольные имена плагинов, гейт остановит 20 знаменитых опасных и пропустит всё остальное в ваш код. Долговечный ответ — перестать писать catch-all-обработчики. Регистрация каждой функции по имени через RegisterUserFunction превращает сам реестр в allowlist и сохраняет смысл проверке ввода формул: когда назначены OnUserFunction или OnUserFunctionEx, движок не может знать, какие имена событие подхватит, и валидация ввода вовсе перестаёт помечать неизвестные имена функций

procedure TImportService.WireFunctions;
begin
  // Одна регистрация на имя: реестр и есть allowlist
  FBook.RegisterUserFunction('DISCOUNT', HandleDiscount);
  FBook.RegisterUserFunction('REGIONRATE', HandleRegionRate);
  // Не назначайте оба события, чтобы неизвестные имена по-прежнему
  // отклонялись на входе и превращались в #NAME? при вычислении
  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 — zero-based variant-массив уже вычисленных аргументов
  if VarIsArray(Args) and (VarArrayHighBound(Args, 1) = 1) then
  begin
    Value := Args[0] * (1 - Args[1]);
    Handled := True;
  end;
end;

Почему гейт обязан срабатывать до вычисления аргументов?

HotXLS сперва проверяет имя функции и отказывает вызову до построения массива аргументов, до обращения к резолверу или любому из реестров и даже до того, как заметит, что ни один обработчик вообще не назначен. Именно этот порядок обезвреживает вложенную формулу из вступления: внешний WEBSERVICE отказан, пока AUDIT_TOKEN всё ещё невычисленный дочерний узел, так что хост-обработчик не выполняется вовсе. Последняя часть порядка важна для логов. Книга без обработчиков и книга с пятью отчитываются одним статусом для =WEBSERVICE(...), так что журнал отказов не меняет смысла, когда кто-то позже подключит обработчик

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;

procedure TImportService.ProbeNestedCall;
var
  Sheet: TXLSWorksheet;
  Eval: TXLSFormulaEvaluationResult;
begin
  FBook.OnUserFunction := HandleUdf;
  Sheet := FBook.Sheets[1];                  // индексы листов начинаются с 1
  Eval := Sheet.EvaluateFormulaAt(1, 1, '=WEBSERVICE(AUDIT_TOKEN())');
  // Ожидаемо: Eval.Status = xlfeUnsafeFunctionDenied, Eval.Value = Null,
  // Eval.Issue.NativeCode = -106 и FAuditLog.Count = 0
end;

Нативный код -106 — публичная константа lxErrorUnsafeFunctionDenied, а Eval.Issue.Message читается как «The formula callback is blocked by the safety policy». xlfeUnsafeFunctionDenied — последний член TXLSFormulaEvaluationStatus, после xlfeFailed. Код, размечающий статусы через case с веткой else для общего провала, подшить отказ под «evaluation failed», если не назовёт статус явно, а для upload-конвейера это ровно тот исход, который стоит вернуть загрузившему

Дефолт книги против опций на вызов TXLSFormulaEvaluationOptions

Флаг книги задаёт дефолт, а опция на вызов имеет последнее слово. TXLSWorkbook.AllowUnsafeFormulaCallbacks и TXLSXWorkbook.AllowUnsafeFormulaCallbacks стартуют как False и управляют обычным пересчётом, Calculate, трёхаргументным EvaluateFormulaAt, EvaluateAt на шаблоне вычисления, формулами списков проверки данных, read-only представлениями и, на XLSX, каждым воркером пула параллельного пересчёта. Любая точка входа, принимающая явную запись TXLSFormulaEvaluationOptions, берёт Options.AllowUnsafeFormulaCallbacks как вердикт для этого вызова и не OR-ит его со свойством книги

Эта асимметрия намеренная, и это правильный дефолт. Доверенная внутренняя задача может авторизовать один RTD-lookup, не переключая всю книгу, а глобально разрешившая себя книга всё ещё может вернуть чувствительное вычисление к отказу. XLSDefaultFormulaEvaluationOptions возвращает флаг как False, так что запись, собранная из этой функции, означает отказ, пока вы не включили его нарочно. Один путь игнорирует флаг полностью: когда Classic-правила условного форматирования вычисляются для отрисовки, колбэки отказаны, что бы ни говорила книга

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

  // книга остаётся заблокированной, один доверенный вызов проходит
  FBook.AllowUnsafeFormulaCallbacks := False;
  Options := XLSDefaultFormulaEvaluationOptions;
  Options.AllowUnsafeFormulaCallbacks := True;
  Eval := Sheet.EvaluateFormulaAt(4, 2, '=WEBSERVICE(B1)', xlfrsA1, Options);

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

Смена свойства книги вдобавок помечает грязной каждую формулу в графе зависимостей, на обоих классах книг. Без этого значение, закэшированное, пока колбэки были разрешены, могло бы отдаваться после их отзыва, или закэшированный отказ мог бы пережить разрешение. Установка свойства в значение, которое оно уже держит, — no-op и кэш не трогает, так что переключение внутри цикла стоит полного пересчёта, только когда значение реально меняется

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

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

var
  Sheet: TXLSWorksheet;
  Policy: TXLSFormulaEntryPolicy;
  Check: TXLSFormulaEntryValidationResult;
begin
  Sheet := FBook.Sheets[1];
  Check := Sheet.ValidateFormulaEntry(3, 1, '=ACME_RATE(B3)');
  // политика по умолчанию, обработчика для ACME_RATE нет:
  // Check.Issue = xlfeiUnknownFunction, Check.Action = xlfeaReject

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

У Classic BIFF8 нет токена для произвольного имени функции, поэтому HotXLS записывает неизвестный вызов так, как Excel пишет аддин-функции. Формула получает токен PtgNameX ($59), чья XTI-запись указывает на аддиновый SUPBOOK с обоими индексами листов, выставленными в $FFFE, за ним следуют токены аргументов и PtgFuncVar с номером функции 255 и счётчиком аргументов, включающим слот имени. Тело-подложка ExternName — шесть нулевых байтов, байт длины и флаг Unicode, имя функции в UTF-16, затем двухбайтовая формула из $1C $17, PtgErr с #REF!. Райтер принимает имена от 1 до 255 символов с не более чем 29 аргументами и нацелен только на BIFF8; сохранение в BIFF5 унести вызов не может. Как эти аддиновые записи SUPBOOK уживаются рядом со ссылками на внешние книги, объясняется в статье о правилах классификации SUPBOOK и XTI для внешних ссылок BIFF

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

XLSX хранит сырой текст функции, ODS хранит свою формулу msoxl:. Во всех трёх форматах файл, сохранивший =WEBSERVICE(...), переоткрывается с нетронутым именем, и его вычисление по умолчанию всё ещё возвращает xlfeUnsafeFunctionDenied, не трогая хост. Сохранение никогда не повышает формуле права: тот же текст, что был отказан до кругосветки, отказан и после неё

Для сервера, пересчитывающего книги, которых он не писал, выдерживающая нагрузку настройка коротка: держите AllowUnsafeFormulaCallbacks в False, регистрируйте функции по имени вместо назначения catch-all-события и передавайте опции на вызов только там, где текст формулы приходит из вашего собственного кода. Справочник по колбэкам, политике ввода и вычислению поставляется с документацией компонента электронных таблиц HotXLS для Delphi