Skip to content

C# スクリプト(Roslyn)をサーバースクリプトに使う ​

第1版作成 最終更新 (日本時間)
確認バージョン1.5.1.01.5.8.1

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 で渡したものだけ
IronPythonimport で届く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" などを含む文字列リテラル

静的な文字面の検査なので、次の方法で回避できます。

csharp
// 文字列の連結で型名を組み立てる
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 の中は次の順です。

  1. 最小の参照(System.Runtime・ExpandoObject のアセンブリ・グローバル型のアセンブリ)と WithImports("System")・WithAllowUnsafe(false)・2 つのリゾルバで ScriptOptions を作る
  2. CSharpScript.Create でコンパイルし、エラーがあれば例外
  3. 構文木の検査
  4. セマンティックモデルの検査
  5. AddHostObject で受け取ったオブジェクトをグローバル型のプロパティに入れる
  6. CancellationToken 付きで RunAsync

タイムアウトの問題 ​

ClearScript の ContinuationCallback は V8 の実行ループに直接割り込みますが、CancellationToken は await の位置でしか確認されません。while (true) { } のような CPU だけのループは止められず、.NET では Thread.Abort() も使えないので、確実に中断する手段がありません。

比較 ​

項目ClearScript(V8)IronPython 3C# スクリプト(Roslyn)
ネイティブ依存あり(V8 のバイナリ)なしなし
初回の起動コスト中中高(コンパイラの起動)
定常の実行速度速い中(動的ディスパッチ)速い(IL → JIT)
サンドボックス原理的保証実装的保証(4 層)検査による実装的保証(回避されやすい)
リフレクションの防止不要clr の封鎖で到達困難完全には防げない
型安全・IDE 支援なしなしあり
並行実行インスタンスごとに独立エンジンは使い捨てScript<T>.RunAsync はスレッドセーフ
タイムアウト確実sys.settraceCPU ループに効かない
リスク深刻度対策
リフレクションによる脱出高セマンティック検査で一部。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 へのパイプラインとホストオブジェクトの橋渡しが難しく、実用段階にない
許可リスト方式の検査上記の許可リスト現実的だが限界あり

導入するなら ​

一般ユーザーが自由に書ける環境には出さず、次の条件をそろえた場合に限ります。

条件内容
作成者を限定テナント管理者・システム管理者だけが作成できる
レビュー本番に出す前のコードレビューを必須にする
検査構文木とセマンティックの検査で既知の危険なパターンを拒否する
監査ログ作成・変更・実行をすべて記録する
プロセス分離(任意)最も高い安全性が要るときはサブプロセスで実行する

関連ページ ​

変更履歴

第1版バックグラウンドサーバースクリプトの仕組みと、サーバースクリプトの他言語対応・DLL 実行・cron スケジュールの改修・設計メモを追加