技術文章

HotXLS 為何預設擋下 WEBSERVICE 等公式 Callback

HotXLS 拒絕把 20 個危險的函式名稱——包括 CALL、REGISTER.ID、WEBSERVICE 與 DDE——導向您的 Delphi 使用者函式 Callback,除非您明確開啟。活頁簿屬性 AllowUnsafeFormulaCallbacks 預設為 False,檢查在任何引數被求值之前就執行,被拒的呼叫回報 xlfeUnsafeFunctionDenied,連一個 handler 都不會被叫到

讓這個功能變成必要的場景再平凡不過。某個服務接受上傳的 XLS 或 XLSX 檔,在伺服器端重新計算,再讀回幾個合計值。宿主應用程式多年前為了兩三個業務函式註冊過一個 OnUserFunction handler,後來某個時候這個 handler 長出了一個來者不拒的分支,把它不認得的東西全部轉交給 plugin 表。團隊裡沒有任何人在儲存格裡打過 =WEBSERVICE(...)。上傳者打了。讓這條公式原封不動地經過開檔、重算與存檔,是檔案保真功能;讓它抵達能開 socket 或開檔案的宿主程式碼,則是授權決定。在 HotXLS 把兩者分開之前,函式庫一直在悄悄替您做這個決定

為什麼保留一條公式會變成執行它的許可?

根因是一條單純的後備路徑。HotXLS 對認得的每個 Excel 函式名稱都會剖析,但不是每個認得的名稱在計算引擎裡都有實作。認得卻未實作的內建函式,以前會落進與真正自訂名稱相同的使用者定義函式後備路徑,所以 CALL 與 REGISTER.ID 跟您的 DISCOUNT 或 REGIONRATE 共用同一條分派路徑。像 WEBSERVICE 或 DDE 這類未知名稱,同樣可能比中活頁簿登錄、全處理程序登錄或某個事件 handler 裡的同名項目。那條後備路徑的機制在HotXLS 如何透過 OnUserFunction 解析自訂函式一文有完整說明;問題在於那條路上沒有任何一環問過:這個名稱本身是不是一個神智正常的宿主該執行的東西

分派順序決定了這裡「未知」的意含。引擎無法原生求值的呼叫,會依序被提供給語彙的 LAMBDA 與 LET 繫結(HotXLS 公式引擎的 closure 支援最先解析它們)、接著是透過 RegisterUserFunction 註冊的活頁簿層級函式、然後是 TXLSWorkbook.RegisterGlobalUserFunction 註冊的全處理程序函式,最後才是 OnUserFunction 與 OnUserFunctionEx 事件。只有當它們全部拒絕,一個真正未知的函式才會變成 #NAME?。lambda 查找之後的每一站都把控制權交給您寫的程式碼,這正是安全檢查必須坐在整條鏈前面、而不是塞進任何一個 handler 裡的原因

HotXLS 如何為危險公式 Callback 設閘:GetValueItemUserFunction 在引數陣列建立或任何解析器執行之前就檢查名稱,巢狀的 =WEBSERVICE(AUDIT_TOKEN()) 以 lxErrorUnsafeFunctionDenied 退出、稽核日誌保持空白,而安全的呼叫則沿著從 LAMBDA 與 LET 繫結一路到 OnUserFunction 的鏈走
只有當每一站都拒絕,真正未知的函式才會變成 #NAME?,這正是安全檢查坐在整條鏈前面、而不是塞進任何一個 handler 裡的原因

HotXLS 預設封鎖哪些函式名稱?

lxCalc.pas 裡的 XLSFormulaCallbackIsUnsafe 持有一個固定的 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 與裸寫法一樣被抓到。這份清單住在計算器邊界,而不是 Classic、XLSX 與 ODS 各剖析器裡,這樣一份 AST、一份 BIFF token 串流與一份轉換後的活頁簿行為才會一致

HotXLS 如何在危險 Callback 比較之前正規化函式名稱:修剪空白、名稱轉大寫、剝掉單層 _XLFN. 或 _XLWS. 前綴,所以 _xlfn.webservice 與裸寫法一樣被抓到,接著結果與 lxCalc.pas 裡固定的 20 名稱拒絕集合精確比對
這份清單橫跨載入原生程式碼、觸及網路、與其他處理程序交談或碰檔案系統的名稱,從 DDE、CALL、WEBSERVICE 到 FWRITE 與 FILE.DELETE

依賴它之前有兩個邊界值得知道。比對是精確的,所以您註冊成 MYWEBSERVICE 的 handler 不受影響;反過來,恰好叫 OPEN 或 RUN 的正當內部 UDF 現在預設被拒。這個拒絕集合也不是替您自己的 handler 做沙箱。如果您的來者不拒分支會執行任意的 plugin 名稱,這道閘只擋住惡名昭彰的危險名字,其他一概不管;治本的修法仍然是一個用 SameText 對明確允許清單比對、對不屬於自己的東西把 Handled 留在 False 的 handler

閘門為什麼必須在引數求值之前執行?

在引數算完之後才觸發的閘門太晚了,因為引數本身就能呼叫您的程式碼。GetValueItemUserFunction 先檢查名稱、以 lxErrorUnsafeFunctionDenied 退出,然後才談得上建立引數陣列、諮詢解析器或任何一個登錄,甚至在它注意到根本沒有指派任何 handler 之前。正是這個順序擊敗了下面的巢狀案例:外層呼叫反正會被拒,但若不是這樣,一個看起來無害的內層 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、求值範本、唯讀檢視,以及 XLSX 上平行重新計算池裡的每個 worker。任何接受明確 TXLSFormulaEvaluationOptions 記錄的進入點,都以該次呼叫的 Options.AllowUnsafeFormulaCallbacks 為裁決,不會與活頁簿屬性做 OR。這個不對稱是刻意的:受信任的內部工作可以授權單次 RTD 查找而不用翻轉整本活頁簿,而全域開啟的活頁簿也能把一次敏感求值強制壓回拒絕

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;

切換活頁簿屬性也會在兩個引擎上把相依圖標成 dirty。少了這一步,在允許 Callback 時算出的快取結果可能在其被撤銷之後仍被端出來,或者一個快取的 xlfeUnsafeFunctionDenied 結果可能活得比一次開啟還久。新狀態被附加在 TXLSFormulaEvaluationStatus 的 xlfeFailed 之後,所以它的序數是 10,所有既有序數的值不變;同樣的尾端附加規則也適用於選項記錄欄位與 IXLSWorkbook 的 getter 和 setter,只是針對舊版建置的消費端仍需要重新編譯

未知與危險的公式文字在存檔時會怎樣?

保留一條公式與執行它現在是兩個分開的問題,而進入政策只回答第一個。任一活頁簿類別的 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 裡,未知呼叫沒有自己的 token,所以 HotXLS 用 Excel 寫增益集函式的方式寫它。公式會拿到一個 PtgNameX token($59),其 XTI 項目指向增益集 SUPBOOK、兩個工作表索引都設為 $FFFE,後面跟著引數 token,以及一個帶函式編號 255 的 PtgFuncVar 與一個把名稱槽位算進去的引數數量。背後的 ExternName 本文是六個零位元組、一個長度位元組與 Unicode 旗標、UTF-16 的函式名稱,然後是兩位元組的 $1C $17——一個裝著 #REF! 的 PtgErr。寫入器拒絕超過 255 字元的名稱、超過 29 個引數,以及 BIFF5 目標。HotXLS 如何把這些增益集 SUPBOOK 項目與外部活頁簿連結一併分類,見BIFF 外部連結的 SUPBOOK 與 XTI 分類規則。XLSX 保留原始函式文字,ODS 保留它的 msoxl: 公式,而且在每種格式裡,存過 =WEBSERVICE(...) 的檔案重新打開時文字完好,預設下照樣求值為 xlfeUnsafeFunctionDenied

HotXLS 如何把未知公式呼叫寫進傳統 BIFF8:公式帶著一個 PtgNameX token,其 XTI 項目指向兩個工作表索引皆為 $FFFE 的增益集 SUPBOOK,接著是引數 token 與函式編號 255 的 PtgFuncVar,背後的 ExternName 本文以裝著 #REF! 的兩位元組 $1C $17 PtgErr 收尾
XLSX 保留原始函式文字、ODS 保留它的 msoxl: 公式,所以存過 =WEBSERVICE(...) 的檔案重新打開時文字完好,預設下照樣求值為 xlfeUnsafeFunctionDenied

如果您的管線要求值不是您自己寫的活頁簿,就把 AllowUnsafeFormulaCallbacks 留在 False,handler 綁在明確的允許清單上,只在公式來源是您自己的地方給予每次呼叫的選項。完整的 Callback、寫入政策與求值 API 都記載於 HotXLS Delphi 試算表元件