試算表引擎會先把引數求值,再呼叫接收它們的函式。於是持有 =WEBSERVICE(AUDIT_TOKEN()) 的儲存格會先跑 AUDIT_TOKEN;如果主機應用程式是靠使用者自訂函式 handler 回答 AUDIT_TOKEN,那個 handler 早已寫好日誌、加完計數、開好連線,任何人都還沒看一眼 WEBSERVICE 這個詞。放進 handler 裡的黑名單太遲;等引數回來再檢查外層名稱的黑名單一樣太遲。拒絕必須在外層名稱上決定,在任何引數被碰、任何主機程式碼被輪到之前
會踩到這件事的場景再平凡不過。服務收下上傳的 XLS 或 XLSX、在伺服器上重算、讀回幾個合計。幾年前有人為兩個業務函式註冊了使用者函式 handler,後來長出一個萬用分支,把任何不認得的名稱轉送進外掛表。團隊裡沒有人往儲存格敲過 =WEBSERVICE(...)——上傳者敲過。讓這條公式原樣通過開啟、重算、存檔是檔案保真的工作;讓它抵達能開 socket、能寫檔的主機程式碼,是授權決策,兩件事該有不同的人負責
保留下來的公式怎麼抵達你的 Delphi 回呼
在 HotXLS 公式引擎裡,計算器無法原生求值的呼叫都掉進同一條分派鏈,危險名稱從兩條路抵達那裡。一條是解析器認得、計算器沒有實作的內建函式:CALL 與 REGISTER.ID 是真實的 Excel 函式名,解析乾乾淨淨,然後落進與你的 DISCOUNT、REGIONRATE 相同的後備。另一條是解析器根本不認得的名稱,例如 WEBSERVICE 或 DDE,它們可能比對到註冊表裡同名條目,或被萬用事件 handler 認領。後備機制本身在HotXLS 如何透過 OnUserFunction 解析自訂函式一文裡有完整說明
鏈的順序決定了「未知」的定義。名稱先送 LAMBDA 機制:LET 繫結的閉包,或公式本身是 LAMBDA 的已定義名稱,兩者都在HotXLS 公式引擎的閉包支援裡描述。之後輪到用 RegisterUserFunction 註冊的活頁簿層級函式,再來是類別方法 TXLSWorkbook.RegisterGlobalUserFunction 註冊的整個處理程序層級函式,最後才是事件:OnUserFunctionEx 有指派就跑,OnUserFunction 只在 OnUserFunctionEx 缺席時跑。每個階段都推辭之後,真正未知的名稱才成為 #NAME?;認得卻沒實作的內建則回報不支援的函式
LAMBDA 查找之後的每個階段都把控制權交給你寫的程式碼。HotXLS 因此把安全檢查放在每個會抵達你程式碼的階段之前,而不是塞進其中一個;檢查也放在計算器本身,而不是三個檔案讀取器裡。從傳統 BIFF token、XLSX 文字或 ODS 的 msoxl: 公式載入的公式,最後都是同一個解析後的運算式,一個檢查蓋住三種格式,轉換過的活頁簿行為與原始檔一致
HotXLS 預設拒絕哪些公式名稱?
除非明確允許 Callback,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 的 handler 不受影響;自家 UDF 恰好叫 OPEN 或 RUN 的,預設被拒,而誘人的修法是把活頁簿的 AllowUnsafeFormulaCallbacks 設成 True,讓那一個名字復活。正解是改函式名。為救一個自家名稱把整本活頁簿全面放行,也同時替檔案裡每條公式——包括不是你寫的那些——重新打開了 CALL、DDE 與 FILE.DELETE
拒絕清單也管不到你自己的 handler。萬用分支若會執行任意外掛名稱,關卡擋下 20 個知名危險名稱,其餘照樣放行到你的程式碼。治本之策是別再寫萬用 handler:用 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 是已求值引數的零起點 variant 陣列
if VarIsArray(Args) and (VarArrayHighBound(Args, 1) = 1) then
begin
Value := Args[0] * (1 - Args[1]);
Handled := True;
end;
end;
為什麼關卡必須跑在引數求值之前?
HotXLS 先查函式名稱,在建立引數陣列之前、在諮詢任何解析器或註冊表之前、甚至在發現根本沒指派 handler 之前,就拒絕這筆呼叫。這個順序正是開場那條巢狀公式的剋星:外層 WEBSERVICE 被拒時,AUDIT_TOKEN 還是個未求值的子節點,主機 handler 於是一步都沒跑。順序的最後一環與日誌有關:沒有 handler 的活頁簿與有五個 handler 的活頁簿,對 =WEBSERVICE(...) 回報同一個狀態,日後有人接上 handler,拒絕日誌的意義也不會變
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 上平行重算池裡的每個工作者。任何收明確 TXLSFormulaEvaluationOptions 記錄的進入點,都以 Options.AllowUnsafeFormulaCallbacks 為該次呼叫的裁決,不會與活頁簿屬性做 OR
這個不對稱是刻意的,而且是對的預設。受信任的內部作業可以只放行一次 RTD 查詢,不必翻動整本活頁簿;全面放行的活頁簿也還能把一次敏感求值壓回拒絕。XLSDefaultFormulaEvaluationOptions 回傳的旗標是 False,用這個函式組記錄,不刻意設定就是拒絕。有一條路完全無視旗標:為渲染而計算傳統條件格式規則時,Callback 一律拒絕,活頁簿怎麼說都一樣
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;
在兩個活頁簿類別上,改動這個屬性都會把相依圖裡的每條公式標記為待重算。少了這一步,Callback 還被允許時快取的值,可能在撤銷之後被端出來;或者反過來,快取的拒絕活過了後來的放行。把屬性設成它已經持有的值是無操作,快取不動,所以在迴圈裡反覆設定,只在值真的變化時才付出整本重算的代價
未知與危險的公式文字,存檔時會怎樣?
留住一條公式與執行它是兩個問題,進入政策只回答第一個。兩個活頁簿類別的 FormulaEntryPolicy 都帶 UnknownFunctionMode 與 UnknownNameMode,預設皆為 xlfusmReject。透過一般 Formula 屬性指派含未知呼叫的公式,會在儲存格值、公式快取或相依關係變動之前,丟出帶「Formula entry rejected: unknown function」的 EArgumentException。ValidateFormulaEntry 以記錄回傳同一個決定,沒有副作用。載入檔案、複製、格式轉換這些受信任路徑不經過使用者進入政策——嚴格的預設絕不能拒掉你只是打開的檔案裡既有的符號
var
Sheet: TXLSWorksheet;
Policy: TXLSFormulaEntryPolicy;
Check: TXLSFormulaEntryValidationResult;
begin
Sheet := FBook.Sheets[1];
Check := Sheet.ValidateFormulaEntry(3, 1, '=ACME_RATE(B3)');
// 預設政策、ACME_RATE 沒有 handler:
// 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、按名稱註冊函式而不指派萬用事件、只在公式文字出自你自己程式碼的地方傳逐呼叫選項。Callback、進入政策與求值的參考資料隨 HotXLS Delphi 試算表元件文件一起出貨