表格引擎先把参数求值,然后才调用接收它们的函数。因此一个持有 =WEBSERVICE(AUDIT_TOKEN()) 的单元格会先跑 AUDIT_TOKEN;如果宿主应用通过用户定义函数处理器应答 AUDIT_TOKEN,那么等任何代码看一眼 WEBSERVICE 这个词时,那个处理器早已写好日志行、加过计数器或打开过连接。放在处理器内部的黑名单太晚了;参数返回之后再检查外层名字同样太晚。拒绝必须在外层名字上决定,在任何参数被碰之前、在宿主写的任何代码得到机会之前
这个问题咬人的场合非常日常。一个服务接收上传的 XLS 或 XLSX 文件,在服务端重算,再读回几个合计数。多年前有人为两个业务函数注册了用户函数处理器,途中它长出了一个兜底分支,把所有不认识的名字转发给插件表。团队里没人往单元格里敲过 =WEBSERVICE(...),上传文件的人敲了。让这条公式在打开、重算、保存的全程保持原样,是文件保真的活儿;让它够到能开套接字、写文件的宿主代码,是授权决定——这两件事需要不同的负责人
一条被保留的公式是怎么够到你 Delphi 回调的
在 HotXLS 公式引擎里,计算器原生算不了的调用都会落进同一条分派链,危险名字从两条路进来。一条路是解析器认识但计算器没有实现的内置函数:CALL 和 REGISTER.ID 是真实的 Excel 函数名,所以它们干净地解析通过,然后和你的 DISCOUNT、REGIONRATE 一起落进同一个兜底。另一条路是解析器完全不认识的名字,比如 WEBSERVICE 或 DDE,它们可以匹配注册表里的同名条目,或被兜底事件处理器认领。那条兜底路径的机制在 HotXLS 如何通过 OnUserFunction 解析自定义函数里有完整说明
链条的顺序决定了这里"未知"的含义。一个名字会先去 LAMBDA 机制:LET 绑定的闭包,或公式为 LAMBDA 的定义名称——两者都在 HotXLS 公式引擎的闭包支持里有描述。之后是用 RegisterUserFunction 注册的工作簿本地函数,再是类方法 TXLSWorkbook.RegisterGlobalUserFunction 注册的进程级函数,最后才是事件。OnUserFunctionEx 赋值了就运行;OnUserFunction 只在 OnUserFunctionEx 没赋值时运行。所有阶段都拒绝之后,真正未知的名字变成 #NAME?;而认识但没有实现的内置名报告的是"不支持函数"
LAMBDA 查找之后的每个阶段都把控制权交给你写的代码。这正是 HotXLS 把安全检查放在每个会够到你代码的阶段之前、而不是塞进其中任何一个的原因,也是检查坐在计算器本身而不是三个文件读取器里的原因。从经典 BIFF token、XLSX 文本或 ODS msoxl: 公式加载的公式,最终都成为同一棵解析后的表达式,所以一个检查覆盖三种格式,转换过的工作簿行为和原件一致
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 的处理器不受影响;恰好叫 OPEN 或 RUN 的自家 UDF 默认被拒,而诱人的修法是把工作簿的 AllowUnsafeFormulaCallbacks 设成 True,让那一个名字复活。正确的做法是改函数名。为了救一个自家名字把整个工作簿放开,同时也为那份文件里的每条公式——包括不是你写的那些——重新启用了 CALL、DDE 和 FILE.DELETE
拒绝集也不会替你圈住自己的处理器。如果兜底分支执行任意插件名,闸门拦下 20 个众所周知的危险名字,其余全部放行到你的代码。持久的答案是别再写兜底处理器。用 RegisterUserFunction 按名字逐个注册,注册表本身就成了白名单,公式入口检查也因此保持有意义:一旦赋值了 OnUserFunction 或 OnUserFunctionEx,引擎无从知道事件会认领哪些名字,入口校验索性不再标记未知函数名
procedure TImportService.WireFunctions;
begin
// 每个名字单独注册:注册表就是白名单
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 是已求值参数组成的零基 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 分支兜住一般失败的代码,会把拒绝归档成"求值失败",除非它显式认出这个状态——而对上传流水线来说,这正是唯一值得回传给上传者的结局
工作簿默认与每次调用的 TXLSFormulaEvaluationOptions
工作簿标志定默认值,每次调用的选项拍最终板。TXLSWorkbook.AllowUnsafeFormulaCallbacks 和 TXLSXWorkbook.AllowUnsafeFormulaCallbacks 初始为 False,管辖普通重算 Calculate、三参数的 EvaluateFormulaAt、求值模板上的 EvaluateAt、数据验证列表公式、只读视图,以及 XLSX 上并行重算池里的每个 worker。任何接受显式 TXLSFormulaEvaluationOptions 记录的入口,都用 Options.AllowUnsafeFormulaCallbacks 作为该次调用的裁决,不会和工作簿属性做 OR
这个不对称是刻意的,也是正确的默认。可信的内部任务可以只授权一次 RTD 查询而不翻开整个工作簿;全局放开的工作簿也能把一次敏感求值压回拒绝。XLSDefaultFormulaEvaluationOptions 返回的记录里该标志是 False,所以从这个函数出发构造记录,默认就是拒绝,除非你刻意设置。有一条路径完全无视这个标志:为渲染而求值经典条件格式规则时,无论工作簿怎么设置,回调一律拒绝
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;
在两个工作簿类上,修改工作簿属性还会把依赖图里的每条公式标脏。没有这一步,回调还被允许时缓存出的值,可能在权限收回后继续被端出来;或者一个缓存的拒绝结果会比放开开关活得更久。把属性设成它已经持有的值是无操作、不动缓存,所以在循环里切换它,只有值真正变化时才付出一次全量重算的代价
未知与危险公式文本在保存时怎么样?
保留一条公式和运行它是两个独立的问题,入口策略只回答第一个。两个工作簿类的 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;
经典 BIFF8 没有承载任意函数名的 token,所以 HotXLS 按 Excel 写加载宏函数的方式写未知调用。公式得到一个 PtgNameX token($59),其 XTI 条目指向加载宏 SUPBOOK,两个工作表索引都设为 $FFFE;后面跟参数 token,以及一个携带函数号 255、参数计数含名字槽位的 PtgFuncVar。背书的 ExternName 主体是六个零字节、一个长度字节加 Unicode 标志、UTF-16 函数名,然后是两字节公式 $1C $17——一个装着 #REF! 的 PtgErr。写入器接受 1 到 255 个字符、至多 29 个参数的名字,且只面向 BIFF8;BIFF5 保存带不动这个调用。这些加载宏 SUPBOOK 条目如何与外部工作簿链接比邻而居,BIFF 外部链接的 SUPBOOK 与 XTI 分类规则有解释
XLSX 保留原始函数文本,ODS 保留 msoxl: 公式。三种格式里,保存过 =WEBSERVICE(...) 的文件重新打开时名字原样保留,默认求值仍然返回 xlfeUnsafeFunctionDenied,不惊动宿主。保存从不给公式升级权限:往返之前被拒的文本,往返之后照样被拒
对一个要重算别人工作簿的服务器,站得住的配置很短:AllowUnsafeFormulaCallbacks 保持 False,按名字注册函数而不是挂兜底事件,只在公式文本出自你自己的代码时传每次调用的选项。回调、入口策略与求值参考随 HotXLS Delphi 电子表格组件文档发布