技術記事

HotXLSの危険な数式コールバック:CALLとWEBSERVICEの遮断

HotXLSは、CALL、REGISTER.ID、WEBSERVICE、DDEをはじめとする20個の危険な数式名を、オプトインしない限りDelphiのユーザー関数コールバックへ回しません。ワークブックプロパティAllowUnsafeFormulaCallbacksのデフォルトはFalseで、チェックは引数が1つでも評価される前に走り、拒否された呼び出しは1つのハンドラも起動せずにxlfeUnsafeFunctionDeniedを報告します

これが必要になったシナリオは、いたって平凡です。あるサービスがアップロードされたXLSやXLSXファイルを受け付け、サーバー側で再計算し、合計をいくつか読み返します。ホストアプリケーションは何年も前にいくつかの業務関数のためにOnUserFunctionハンドラを登録しており、その過程のどこかで、ハンドラは認識できないものを何でもプラグインテーブルへ転送するcatch-all分岐を生やしていました。チームの誰も=WEBSERVICE(...)をセルに打ち込んだことはありません。アップロードした誰かが打ち込んだのです。その数式をオープン、再計算、保存を通してそのまま保つのはファイル忠実性の機能です。それがソケットやファイルを開けるホストコードへ届くのを許すのは認可の判断であり、HotXLSがこの2つを切り離すまで、ライブラリはあなたに代わって静かにその判断を下していました

数式を保存することが実行の許可になっていた理由

根因は1本のフォールバック経路でした。HotXLSは知っているExcel関数名をすべてパースしますが、知っている名前すべてに計算エンジンの実装があるわけではありません。認識はするが未実装のビルトインは、正真正銘のカスタム名と同じユーザー定義関数フォールバックに落ちていました。CALLとREGISTER.IDはあなたのDISCOUNTやREGIONRATEとディスパッチ経路を共有していたのです。WEBSERVICEやDDEのような未知の名前も同様に、ワークブックレジストリ、プロセス全体のレジストリ、イベントハンドラにある同名エントリにマッチし得ました。このフォールバックの仕組み自体は、HotXLSがOnUserFunction経由でカスタム関数を解決する仕組みで扱っています。問題は、この経路のどこにも「その名前自体が、まともなホストが実行してよいものか」を尋ねる箇所がなかったことです

ここで言う「未知」の意味には、ディスパッチ順序が効いてきます。エンジンがネイティブに評価できない呼び出しは、まずHotXLS数式エンジンのクロージャサポートが最初に解決する字義的なLAMBDAとLETの束縛に回され、次にRegisterUserFunctionで登録されたワークブックローカルの関数、続いてTXLSWorkbook.RegisterGlobalUserFunctionによるプロセス全体の関数、最後にOnUserFunctionとOnUserFunctionExイベントへと順に差し出されます。そのすべてが断って初めて、本当に未知の関数は#NAME?になります。ラムダ検索より後のどの段階も、あなたの書いたコードへ制御を渡します。安全性チェックがどれか1つのハンドラの中ではなく、チェーン全体の手前に置かれなければならないのはまさにそのためです

HotXLSが危険な数式コールバックを遮断する仕組み:GetValueItemUserFunctionは引数配列が構築される前、どのリゾルバが走る前に関数名をチェックするため、ネストした=WEBSERVICE(AUDIT_TOKEN())はlxErrorUnsafeFunctionDeniedで抜け、監査ログは空のままです。安全な呼び出しはLAMBDAとLETの束縛からOnUserFunctionまでチェーンをたどります
すべての段階が断って初めて、本当に未知の関数は#NAME?になります。安全性チェックがどれか1つのハンドラの中ではなくチェーン全体の手前に置かれるのはそのためです

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.の接頭辞を1つ剥ぎます。新しいExcelビルドが書く_xlfn.webserviceも、素の綴りと同じように捕まります。このリストがClassic、XLSX、ODSの各パーサーではなく電卓境界に置かれているおかげで、1つのAST、1つのBIFFトークンストリーム、1つの変換済みワークブックが同じ挙動を保ちます

HotXLSが危険コールバック比較前に関数名を正規化する仕組み:空白を刈り込み、名前を大文字化し、_XLFN.か_XLWS.の接頭辞を1つ剥ぐため、_xlfn.webserviceは素の綴りと同じように捕まり、その結果がlxCalc.pasの20名の固定拒否セットと正確に照合されます
リストは、ネイティブコードをロードし、ネットワークへ手を伸ばし、他プロセスと会話し、ファイルシステムに触れる名前を、DDE、CALL、WEBSERVICEからFWRITE、FILE.DELETEまで網羅します

これに頼る前に知っておくべき2つの縁があります。マッチは正確一致なので、MYWEBSERVICEとして登録したハンドラは影響を受けません。逆に、たまたまOPENやRUNという名前の正当な社内UDFは、デフォルトで拒否されるようになります。拒否セットは自分のハンドラ用のサンドボックスでもありません。catch-all分岐が任意のプラグイン名を実行するなら、このゲートが止めるのは有名な危険名だけで、それ以外は何も止めません。恒久の修正は依然として、明示的な許可リストをSameTextで照合し、所有しないものはHandledをFalseのままにするハンドラです

ゲートは引数評価の前に走らなければならない理由

引数が計算された後に発火するゲートでは遅すぎます。引数そのものがあなたのコードを呼べるからです。GetValueItemUserFunctionはまず名前をチェックし、引数配列を構築する前、リゾルバやどちらのレジストリに尋ねる前、さらにはハンドラが1つも割り当てられていないことに気づく前でさえ、lxErrorUnsafeFunctionDeniedで抜けます。この順序があるからこそ、下のネストしたケースが防げます。外側の呼び出しはどうせ拒否されますが、そうでなければ無害そうな内側の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、2引数のEvaluateFormulaAt、評価テンプレート、読み取り専用ビュー、そしてXLSXでは並列再計算プールの全ワーカーを統括します。明示的なTXLSFormulaEvaluationOptionsレコードを受け取るエントリポイントはどれも、その呼び出しの判定としてOptions.AllowUnsafeFormulaCallbacksを採用し、ワークブックプロパティとのORを取りません。この非対称は意図的です。信頼された内部ジョブはワークブック全体をひっくり返さずに1回のRTD検索を許可でき、グローバルにオプトインしたワークブックでも、機微な評価を拒否へ強制できます

var
  Options: TXLSFormulaEvaluationOptions;
  Eval: TXLSFormulaEvaluationResult;
begin
  // ワークブックはロックダウンのまま、信頼された1呼び出しだけ通す
  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マークもします。このステップがないと、コールバックが許可されていた時期に計算されたキャッシュ結果が、取り消された後にも供給され得ますし、キャッシュされたxlfeUnsafeFunctionDeniedの結果がオプトインより長生きし得ます。新しいステータスはTXLSFormulaEvaluationStatusのxlfeFailedの後に追加されたため、序数は10であり、既存の序数はすべて値を保ちます。同じ末尾追加ルールはオプションレコードのフィールドとIXLSWorkbookのゲッター・セッターにも当てはまりますが、古いリリース向けにビルドされたコンシューマは再コンパイルが必要です

保存時に未知・危険な数式テキストはどうなるのか

数式を保持することと実行することは今や別の問いであり、エントリポリシーが答えるのは前者だけです。どちらのワークブッククラスの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;

クラシックBIFF8では未知の呼び出しに専用のトークンがないため、HotXLSはExcelがアドイン関数を書くのと同じ方法で書き込みます。数式はPtgNameXトークン($59)を得ます。そのXTIエントリはアドインSUPBOOKを指し、両シートインデックスは$FFFEにセットされます。続いて引数トークン、そして関数番号255と名前スロットを含む引数数を運ぶPtgFuncVarが並びます。裏付けのExternNameボディは、ゼロ6バイト、長さバイトとUnicodeフラグ、UTF-16の関数名、そして2バイトの数式$1C $17、つまり#REF!を保持するPtgErrで構成されます。ライターは255文字超の名前、29引数超、そしてBIFF5ターゲットを拒否します。外部ワークブックリンクとの並びでHotXLSがこれらのアドインSUPBOOKエントリをどう分類するかは、BIFF外部リンクのSUPBOOKとXTI分類ルールで説明しています。XLSXは生の関数テキストを保持し、ODSはmsoxl:数式を保持します。どの形式でも、=WEBSERVICE(...)を保存したファイルはテキストがそのままの状態で再オープンされ、デフォルトのままxlfeUnsafeFunctionDeniedと評価されます

HotXLSが未知の数式呼び出しをクラシックBIFF8へ書き込む方法:数式はPtgNameXトークンを運び、そのXTIエントリは両シートインデックス$FFFEでアドインSUPBOOKを指し、続いて引数トークンと関数番号255のPtgFuncVarが並び、裏付けは2バイトの$1C $17、#REF!を保持するPtgErrで終わるExternNameボディです
XLSXは生の関数テキストを、ODSはmsoxl:数式を保持するため、=WEBSERVICE(...)を保存したファイルはテキストがそのままの状態で再オープンされ、デフォルトのままxlfeUnsafeFunctionDeniedと評価されます

自分が書いていないワークブックを評価するパイプラインなら、AllowUnsafeFormulaCallbacksはFalseのままにし、ハンドラは明示的な許可リストに置き、呼び出しごとのオプションは数式の出所が自分である場所にだけ与えてください。コールバック、エントリポリシー、評価APIの全体は、HotXLS Delphiスプレッドシートコンポーネントのドキュメントに載っています