Табличные движки вычисляют аргументы прежде, чем вызовут принимающую их функцию. Ячейка с =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 блокирует по умолчанию?
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, ловится ровно как голое написание
Совпадение точное, что режет в обе стороны. Обработчик, зарегистрированный как 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
XLSX хранит сырой текст функции, ODS хранит свою формулу msoxl:. Во всех трёх форматах файл, сохранивший =WEBSERVICE(...), переоткрывается с нетронутым именем, и его вычисление по умолчанию всё ещё возвращает xlfeUnsafeFunctionDenied, не трогая хост. Сохранение никогда не повышает формуле права: тот же текст, что был отказан до кругосветки, отказан и после неё
Для сервера, пересчитывающего книги, которых он не писал, выдерживающая нагрузку настройка коротка: держите AllowUnsafeFormulaCallbacks в False, регистрируйте функции по имени вместо назначения catch-all-события и передавайте опции на вызов только там, где текст формулы приходит из вашего собственного кода. Справочник по колбэкам, политике ввода и вычислению поставляется с документацией компонента электронных таблиц HotXLS для Delphi