スプレッドシートエンジンは、引数を受け取る関数を呼ぶ前に引数を評価します。したがって=WEBSERVICE(AUDIT_TOKEN())を握るセルはまずAUDIT_TOKENを実行し、ホストアプリケーションがAUDIT_TOKENにユーザー定義関数ハンドラーで応答しているなら、そのハンドラーは誰かがWEBSERVICEという語に目を向ける頃には、すでにログ行を書き、カウンターを進め、接続を開いています。ハンドラーの内側のブロックリストでは遅すぎます。引数が返ってきた後に外側の名前を検査するやり方も同じです。拒否は外側の名前について、どの引数にも触れられず、ホストが書いたコードにも順番が回る前に決まらなければなりません
これが牙を剥くのは、いたって平凡な場面です。あるサービスがアップロードされたXLSやXLSXファイルを受け付け、サーバー側で再計算し、合計をいくつか読み返します。何年も前に誰かが2つの業務関数のためにユーザー関数ハンドラーを登録し、その過程のどこかで、認識できない名前を何でもプラグインテーブルへ転送するcatch-all分岐を生やしていました。チームの誰も=WEBSERVICE(...)をセルに打ち込んだことはありません。アップロードした誰かが打ち込んだのです。オープン、再計算、保存を通してその数式をそのまま保つのはファイル忠実性の仕事です。それがソケットを開けたりファイルを書いたりできるホストコードへ届くのを許すのは認可の判断であり、この2つには別の担当者が要ります
保護された数式がどうDelphiのコールバックに届くのか
HotXLSの数式エンジンでは、電卓がネイティブに評価できない呼び出しは1本の共有ディスパッチチェーンに流れ、危険な名前はそこへ2つの道から到着します。1つ目の道は、パーサーは認識するが電卓に実装のないビルトイン関数です。CALLとREGISTER.IDは本物のExcel関数名なので、きれいにパースされ、あなたのDISCOUNTやREGIONRATEと同じフォールバックへ落ちます。もう1つの道は、パーサーがまったく知らない名前、たとえばWEBSERVICEやDDEで、レジストリの同名エントリーに一致するか、catch-allイベントハンドラーに主張されます。そのフォールバックの仕組みは、HotXLSがOnUserFunction経由でカスタム関数を解決する仕組みで扱っています
チェーンの順序が「未知」の意味を決めます。名前は何よりも先にLAMBDAの仕組みへ回されます。LETに束縛されたクロージャー、あるいは数式がLAMBDAである定義名で、いずれもHotXLS数式エンジンのクロージャサポートで述べたものです。その次はRegisterUserFunctionで登録されたワークブックローカルの関数、続いてクラスメソッドTXLSWorkbook.RegisterGlobalUserFunctionによるプロセス全体の関数、最後がイベントです。OnUserFunctionExは割り当てられていれば走り、OnUserFunctionはOnUserFunctionExがないときだけ走ります。すべての段階が断って初めて、本当に未知の名前は#NAME?になり、認識はするが未実装のビルトインは未対応関数として報告されます
LAMBDA検索より後のどの段階も、あなたの書いたコードへ制御を渡します。だからこそHotXLSの安全性チェックは、どれか1つの段階の内側ではなく、あなたのコードへ届くすべての段階の手前に置かれ、しかも3つのファイルリーダーではなく電卓そのものに置かれているのです。Classic BIFFトークンからでも、XLSXテキストからでも、ODSのmsoxl:数式からでも、ロードされた数式は同じパース済み式になるため、1つの検査が3つの形式をすべて覆い、変換済みワークブックは元のワークブックと同じ振る舞いをします
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.の接頭辞を1つ剥ぎます。新しいExcelビルドが_xlfn.webserviceと保存したファイルも、素の綴りとまったく同じように捕まります
マッチは正確一致であり、これは両方向に働きます。MYWEBSERVICEとして登録したハンドラーは影響を受けません。逆に、たまたまOPENやRUNという名前の社内UDFはデフォルトで拒否され、誘惑的な修正は、その1つの名前を復活させるためにワークブックのAllowUnsafeFormulaCallbacksをTrueにすることです。代わりに関数の名前を変えてください。1つの社内名を救うためにワークブック全体をオプトインすると、そのファイル内のすべての数式、あなたが書いていない数式を含めて、CALL、DDE、FILE.DELETEまでもう一度有効にします
拒否セットは、自分のハンドラーを囲い込んではくれません。catch-all分岐が任意のプラグイン名を実行するなら、このゲートが止めるのは20の有名な危険名だけで、それ以外はすべてあなたのコードへ通します。恒久の答えは、catch-allハンドラーを書くのをやめることです。関数をRegisterUserFunctionで1つずつ名前付きで登録すれば、レジストリそのものが許可リストになり、数式エントリーの検査も意味を保ちます。OnUserFunctionやOnUserFunctionExが割り当てられていると、イベントがどの名前を主張するかエンジンには分からないため、エントリー検証は未知の関数名に対する指摘そのものを止めてしまいます
procedure TImportService.WireFunctions;
begin
// 名前ごとに1つの登録:レジストリが許可リストになる
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は評価済み引数の0始まりvariant配列
if VarIsArray(Args) and (VarArrayHighBound(Args, 1) = 1) then
begin
Value := Args[0] * (1 - Args[1]);
Handled := True;
end;
end;
ゲートは引数評価の前に走らなければならない理由
HotXLSはまず関数名を検査し、引数配列を構築する前、リゾルバーやどちらのレジストリーに尋ねる前、さらにはハンドラーが1つも割り当てられていないことに気づく前でさえ、呼び出しを拒否します。この順序こそが冒頭のネストした数式を打ち破ります。外側のWEBSERVICEは、AUDIT_TOKENがまだ未評価の子ノードであるうちに拒否されるため、ホストのハンドラーは一度も走りません。順序の最後の部分はロギングのために効いてきます。ハンドラーなしのワークブックも5つ備えたワークブックも、=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の後に来ます。汎用の失敗用にelse分岐を備えたcaseでステータスを振り分けるコードは、このステータスを明示的に名指ししない限り、拒否を「評価失敗」に分類してしまいます。アップロードパイプラインにとって、アップロードした人へ報告する価値のあるのはこの結果こそです
ワークブック既定と呼び出しごとのTXLSFormulaEvaluationOptions
ワークブックのフラグがデフォルトを設定し、呼び出しごとのオプションが最終決定を持ちます。TXLSWorkbook.AllowUnsafeFormulaCallbacksとTXLSXWorkbook.AllowUnsafeFormulaCallbacksはFalseで始まり、通常の再計算、Calculate、3引数のEvaluateFormulaAt、評価テンプレート上のEvaluateAt、データ検証リストの数式、読み取り専用ビュー、そしてXLSXでは並列再計算プールのすべてのワーカーを統括します。明示的なTXLSFormulaEvaluationOptionsレコードを受け取るエントリーポイントはどれも、その呼び出しの判定としてOptions.AllowUnsafeFormulaCallbacksを採用し、ワークブックプロパティとのORは取りません
この非対称は意図的で、正しいデフォルトです。信頼された内部ジョブは、ワークブック全体をひっくり返さずに1回のRTD検索を許可でき、グローバルにオプトインしたワークブックでも、機微な評価を拒否へ強制できます。XLSDefaultFormulaEvaluationOptionsはこのフラグをFalseで返すため、その関数からレコードを組み立てるなら、意図的に設定しない限り拒否です。このフラグを完全に無視する経路が1つだけあります。描画のためにClassic条件付き書式ルールを評価するときは、ワークブックが何と言おうとコールバックは拒否されます
var
Sheet: TXLSWorksheet;
Options: TXLSFormulaEvaluationOptions;
Eval: TXLSFormulaEvaluationResult;
begin
Sheet := FBook.Sheets[1];
// ワークブックはロックダウンのまま、信頼された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;
ワークブックプロパティの変更は、両方のワークブッククラスで、依存グラフ内のすべての数式をdirtyとしてマークもします。これがないと、コールバックが許可されていた時期にキャッシュされた値が、取り消された後にも供給され得ますし、キャッシュされた拒否がオプトインより長生きし得ます。すでに保持している値への設定はノーオプでキャッシュに触れないため、ループ内での切り替えは、値が実際に変わったときにだけ再計算のコストを払います
保存時に未知・危険な数式テキストはどうなるのか
数式を保持することと実行することは別の問いであり、エントリーポリシーが答えるのは前者だけです。どちらのワークブッククラスの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のハンドラなし:
// 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;
Classic BIFF8には任意の関数名のためのトークンがないため、HotXLSは未知の呼び出しを、Excelがアドイン関数を書くのと同じ方法で書きます。数式はPtgNameXトークン($59)を得ます。そのXTIエントリーはアドインSUPBOOKを指し、両シートインデックスは$FFFEにセットされ、続いて引数トークン、そして関数番号255と名前スロットを含む引数数を運ぶPtgFuncVarが並びます。裏付けのExternNameボディは、ゼロ6バイト、長さバイトとUnicodeフラグ、UTF-16の関数名、そして2バイトの数式$1C $17、つまり#REF!を保持するPtgErrで構成されます。ライターは1〜255文字の名前と最大29引数を受け付け、対象はBIFF8のみです。BIFF5保存ではこの呼び出しは運べません。これらのアドインSUPBOOKエントリーが外部ワークブックリンクとどう並ぶかは、BIFF外部リンクのSUPBOOKとXTI分類ルールで説明しています
XLSXは生の関数テキストを保持し、ODSはmsoxl:数式を保持します。3つの形式のどこでも、=WEBSERVICE(...)を保存したファイルは名前がそのままの状態で再オープンされ、デフォルトの評価はホストを呼ばずにxlfeUnsafeFunctionDeniedを返します。保存が数式の権限を昇格させることはありません。往復の前に拒否されたテキストは、往復の後にも拒否されます
自分が著者でないワークブックを再計算するサーバーなら、持ちこたえる設定は短いものです。AllowUnsafeFormulaCallbacksはFalseのままにし、catch-allイベントを割り当てる代わりに関数を名前付きで登録し、呼び出しごとのオプションは数式テキストの出所が自分のコードである場所にだけ渡します。コールバック、エントリーポリシー、評価のリファレンスは、HotXLS Delphiスプレッドシートコンポーネントのドキュメントに同梱されています