技術文章

HotXLS 危險公式 Callback 的把關:CALL 與 WEBSERVICE

試算表引擎會先把引數求值,再呼叫接收它們的函式。於是持有 =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:在建立引數陣列、執行任何解析器之前就先對照拒絕清單檢查函式名稱,巢狀的 =WEBSERVICE(AUDIT_TOKEN()) 因此被以 xlfeUnsafeFunctionDenied 拒絕、稽核日誌保持空白;安全呼叫則沿 LAMBDA 與 LET 繫結一路走到 OnUserFunction
每個階段都拒絕之後,真正未知的函式才會變成 #NAME?——安全檢查因此站在回呼階段的前面,而不是躲進任何一個 handler 裡

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 的檔案,與裸寫法一樣被逮到

HotXLS 在危險 Callback 比對前如何正規化函式名稱:去除首尾空白、轉大寫、剝掉單一個 _XLFN. 或 _XLWS. 前綴,_xlfn.webservice 因此與裸寫法一樣被抓到;結果再與固定的 20 個名稱拒絕清單做精確比對
清單涵蓋載入原生程式碼、觸及網路、與其他處理程序交談或動到檔案系統的名稱,從 DDE、CALL、WEBSERVICE 一路到 FWRITE 與 FILE.DELETE

比對是精確的,這把雙面刃兩面都利。你註冊成 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 分類規則

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

XLSX 保留原始函式文字,ODS 保留 msoxl: 公式。三種格式裡,存過 =WEBSERVICE(...) 的檔案重開時名稱原封不動,預設求值照樣回傳 xlfeUnsafeFunctionDenied,不會驚動主機。存檔從不替公式升級權限:來回一趟之前被拒的文字,之後一樣被拒

對一台重算別人活頁簿的伺服器,站得住的設定很簡短:AllowUnsafeFormulaCallbacks 維持 False、按名稱註冊函式而不指派萬用事件、只在公式文字出自你自己程式碼的地方傳逐呼叫選項。Callback、進入政策與求值的參考資料隨 HotXLS Delphi 試算表元件文件一起出貨