C# スクリプト(Roslyn)をサーバースクリプトに使う
1.5.8.1 のプリザンターでは、サーバースクリプトは ClearScript(V8)で動く JavaScript だけで、Microsoft.CodeAnalysis 系のパッケージも参照していません(Implem.Pleasanter.csproj)。このページは、Roslyn Scripting API(Microsoft.CodeAnalysis.CSharp.Scripting)で C# スクリプトを使えるようにする場合の設計メモで、本体の標準機能ではありません。調査は 1.5.1.0 を対象に行いました。
動かすところまでの試作手順は C# スクリプト・Python は使えるか にあります。このページでは、一般のユーザーがスクリプトを書く環境に出せるか(サンドボックスを作れるか)を評価します。
結論:V8 と同じ分離レベルは作れません。一般ユーザー向けのサーバースクリプトには勧めず、管理者だけが書く「信頼済みスクリプト」に限るなら検討の余地があります。
Roslyn Scripting API の使い方
| パッケージ | 内容 | ライセンス |
|---|---|---|
Microsoft.CodeAnalysis.CSharp.Scripting | スクリプティング API(調査時 4.12.0) | MIT |
Microsoft.CodeAnalysis.CSharp・Microsoft.CodeAnalysis.Common | 依存するコンパイラ | MIT |
- ホストオブジェクトは、ClearScript の
AddHostObjectのように 1 つずつ渡すのではなく、グローバル型(model・context・itemsなどをプロパティに持つクラス)を定義し、そのインスタンスをRunAsync(globals)で渡します。プロパティの型がコンパイル時にチェックされます。model・saved・columnsなどのExpandoObjectはdynamicで受けます。 CSharpScript.Create<object>(code, options, globalsType)でコンパイルと実行を分けられ、コンパイル済みのScript<T>は再実行が速いのでキャッシュできます。- 初回はコンパイラの読み込みで 1〜2 秒、コンパイルに数百ミリ秒かかります。スクリプトごとに IL が作られてメモリを使うので、大量に実行するなら
AssemblyLoadContextのアンロードも要ります。 - 実行は非同期(
RunAsync)なので、既存の同期のインターフェースに合わせるにはGetAwaiter().GetResult()で待ちます。
なぜサンドボックスが難しいか
C# スクリプトは IL にコンパイルされ、ホストと同じ .NET ランタイムで動きます。スクリプトの実行に必須の System.Runtime(typeof(object).Assembly)に System.IO.File・System.Diagnostics.Process・System.Reflection なども入っているため、参照を最小にしてもこれらの型に届きます。
| エンジン | OS の API | .NET の API への経路 |
|---|---|---|
| V8(ClearScript) | エンジンに存在しない | AddHostObject で渡したものだけ |
| IronPython | import で届く | import clr という関門を閉じれば止まる |
| C# スクリプト | 同じランタイムにある | 関門が無い。参照アセンブリの型すべてに届く |
ScriptOptions で防げるもの・防げないもの
| 項目 | 防げるか | 方法・理由 |
|---|---|---|
#r(アセンブリ参照の追加) | できる | MetadataReferenceResolver のサブクラスで常に拒否 |
#load(外部スクリプトの読み込み) | できる | SourceReferenceResolver のサブクラスで常に拒否 |
自動の using | できる | WithImports で限定 |
unsafe コード | できる | WithAllowUnsafe(false)(既定) |
完全修飾名でのアクセス(System.IO.File.ReadAllText(...)) | できない | WithImports は using を足すだけ |
リフレクション・typeof().Assembly | できない | 制御手段が無い |
文字列からの型の取得(Type.GetType("System.IO.File")) | できない | 制御手段が無い |
dynamic 経由の呼び出し | できない | ExpandoObject のために dynamic は禁止できない |
P/Invoke(DllImport・extern) | 構文木の検査で検出 | 静的解析頼み |
| AppDomain での分離 | できない | .NET Core 以降で廃止。AssemblyLoadContext は型へのアクセスを制限しない |
検査による防御
ScriptOptions では足りないので、コンパイル後に検査して、危ない API を使うスクリプトを実行前に拒否します。
第 1 層:構文木の検査
SyntaxTree をたどり、次を見つけたら拒否します。
usingディレクティブ・メンバーアクセス・typeofが、System.IO・System.Diagnostics・System.Net・System.Reflection・System.Runtime.InteropServices・System.Runtime.Loader・System.Threading・System.Security・Microsoft.Win32などで始まるProcess・File・Directory・Assembly・Type・Activator・Marshal・Thread・Task・Environment・AppDomain・Consoleなどの識別子unsafe・extern・fixed・ポインタ関連の構文"System.IO"などを含む文字列リテラル
静的な文字面の検査なので、次の方法で回避できます。
// 文字列の連結で型名を組み立てる
var type = Type.GetType("System" + ".IO" + ".File");
// 必ず使える typeof(object) からアセンブリ内の型を列挙する
var fileType = typeof(object).Assembly.GetTypes().First(t => t.FullName == "System.IO.File");
// char 配列で文字列リテラルの検査を避ける
var name = new string(new[] { 'S', 'y', 's', 't', 'e', 'm' });第 2 層:セマンティックモデルの検査
compilation.GetSemanticModel(syntaxTree) でコンパイラが解決した型を見て、シンボルの名前空間・型がブロック対象か、System.Type のメソッドを呼んでいないかを調べます。using の別名や var の推論の先も追えるのが利点です。
それでも、dynamic の呼び出し(コンパイル時に解決されない)、Type.GetType(string) で実行時に得た型、System.Linq.Expressions による実行時のコード生成は追えません。System.Type をブロックすると typeof() 自体が使えなくなるので、どこまで制限するかはトレードオフです。
許可リスト方式
ブロックリストではなく、使ってよい型(String・Int32・DateTime・List<T>・Dictionary<TKey,TValue>・Enumerable・StringBuilder・Regex・Math・JsonConvert・ExpandoObject など)だけを許し、ホストオブジェクト(Implem.Pleasanter アセンブリ)のメンバーは常に許す方式が最も現実的です。ただし dynamic はやはり追えず、許可リストが厳しいと書けることが大きく減り、型を足すたびに保守が要り、オープンジェネリックの判定も複雑です。
エンジンの構成
Python 版 と同じ IScriptEngine に合わせ、CSharpScriptEngine を作ります。Execute の中は次の順です。
- 最小の参照(
System.Runtime・ExpandoObjectのアセンブリ・グローバル型のアセンブリ)とWithImports("System")・WithAllowUnsafe(false)・2 つのリゾルバでScriptOptionsを作る CSharpScript.Createでコンパイルし、エラーがあれば例外- 構文木の検査
- セマンティックモデルの検査
AddHostObjectで受け取ったオブジェクトをグローバル型のプロパティに入れるCancellationToken付きでRunAsync
タイムアウトの問題
ClearScript の ContinuationCallback は V8 の実行ループに直接割り込みますが、CancellationToken は await の位置でしか確認されません。while (true) { } のような CPU だけのループは止められず、.NET では Thread.Abort() も使えないので、確実に中断する手段がありません。
比較
| 項目 | ClearScript(V8) | IronPython 3 | C# スクリプト(Roslyn) |
|---|---|---|---|
| ネイティブ依存 | あり(V8 のバイナリ) | なし | なし |
| 初回の起動コスト | 中 | 中 | 高(コンパイラの起動) |
| 定常の実行速度 | 速い | 中(動的ディスパッチ) | 速い(IL → JIT) |
| サンドボックス | 原理的保証 | 実装的保証(4 層) | 検査による実装的保証(回避されやすい) |
| リフレクションの防止 | 不要 | clr の封鎖で到達困難 | 完全には防げない |
| 型安全・IDE 支援 | なし | なし | あり |
| 並行実行 | インスタンスごとに独立 | エンジンは使い捨て | Script<T>.RunAsync はスレッドセーフ |
| タイムアウト | 確実 | sys.settrace | CPU ループに効かない |
| リスク | 深刻度 | 対策 |
|---|---|---|
| リフレクションによる脱出 | 高 | セマンティック検査で一部。dynamic で回避される |
dynamic による型チェックの回避 | 高 | 完全な対策は無い |
| CPU の無限ループ | 中 | 別スレッドでの実行。確実な中断は無い |
| メモリの枯渇 | 中 | プロセス・OS 側の制限 |
Expression<T> による動的コード生成 | 中 | System.Linq.Expressions をブロック |
P/Invoke・extern | 中 | 構文木の検査で検出 |
#r・#load | 低 | リゾルバで完全に防げる |
C# スクリプトが優れるのは、型安全・IntelliSense・詳しいコンパイルエラー・実行速度・ホストと同じ言語であることです。安全性では V8 にも IronPython にも劣ります。
より安全に動かす別案
| 案 | 内容 | 評価 |
|---|---|---|
| プロセス分離 | 制限付きユーザー・ファイル・ネットワーク制限をかけた別プロセスで実行し、標準入出力や名前付きパイプでやり取りする | 安全だが、ホストオブジェクトの直列化と通信が重く、model.ClassA = 'test' のような軽い値操作には過剰 |
| WebAssembly(WASI)内で実行 | WASM のサンドボックスに入れる | .NET から WASM へのパイプラインとホストオブジェクトの橋渡しが難しく、実用段階にない |
| 許可リスト方式の検査 | 上記の許可リスト | 現実的だが限界あり |
導入するなら
一般ユーザーが自由に書ける環境には出さず、次の条件をそろえた場合に限ります。
| 条件 | 内容 |
|---|---|
| 作成者を限定 | テナント管理者・システム管理者だけが作成できる |
| レビュー | 本番に出す前のコードレビューを必須にする |
| 検査 | 構文木とセマンティックの検査で既知の危険なパターンを拒否する |
| 監査ログ | 作成・変更・実行をすべて記録する |
| プロセス分離(任意) | 最も高い安全性が要るときはサブプロセスで実行する |