C# スクリプト・Python は使えるか
標準のプリザンターでは、サーバースクリプトに使える言語は JavaScript だけです。 サーバースクリプトは ClearScript(V8 エンジン)で実行されています。
このページでは次の 2 つを紹介します。いずれも本体コードの改変が必要で、標準機能ではありません。
- C# スクリプト: 本体を改変し、
modelの取得・設定が動くところまで試作した例 - Python: IronPython で実現できるかの調査と、サンドボックスの設計(Python の調査は 1.5.1.0 が対象)
詳しい設計は改修・設計メモの サーバースクリプトを Python で書けるようにする、C# スクリプト(Roslyn)をサーバースクリプトに使う、TypeScript・Lua などを含めた サーバースクリプトに使える言語の比較 にあります。
本体改変について
試すには、Visual Studio や Visual Studio Code など、ビルドして実行する環境が必要です。ビルド環境は 公式リポジトリの CONTRIBUTING.md を参照してください。
現在の仕組み:ClearScript(V8)
ClearScript は、Microsoft が開発している .NET 向けのスクリプトエンジンホスティングライブラリです。Google の V8 エンジンを .NET アプリケーションに組み込めます。プリザンターは NuGet パッケージ Microsoft.ClearScript.Complete を参照しています。
<PackageReference Include="Microsoft.ClearScript.Complete" Version="7.5.0" />バージョンは調査時点(1.5.1.0)のものです。1.5.8.1 のソースでは 7.5.1 を参照しています(Implem.Pleasanter.csproj)。ScriptEngine のコンストラクタは 1.5.8.1 でも次のとおりです(ScriptEngine.cs)。
ScriptEngine クラスは V8ScriptEngine を薄くラップしたものです。
public ScriptEngine(bool debug)
{
var flags = V8ScriptEngineFlags.EnableDateTimeConversion;
if (debug)
{
flags |= V8ScriptEngineFlags.EnableDebugging
| V8ScriptEngineFlags.EnableRemoteDebugging;
}
v8ScriptEngine = new V8ScriptEngine(flags);
}実行時には AddHostObject で C# のオブジェクト(context、model、items、columns など)を JavaScript 側に公開します。JavaScript から見えるのは、この AddHostObject で渡されたオブジェクトと、AddHostType で渡された型(1.5.8.1 では Newtonsoft.Json.JsonConvert)だけです(ServerScriptUtilities.cs)。
ClearScript(V8)が安全な理由
V8 はもともとブラウザ用のエンジンで、ファイル操作・プロセス起動・ネットワーク接続・環境変数アクセス・OS シャットダウンといった OS 操作の API をエンジン自体が持っていません(Node.js の fs や child_process は Node.js が独自に追加したものです)。そのため、AddHostObject で渡したもの以外には原理的にアクセスできません。サンドボックスが設計上そのまま手に入る、というのが ClearScript(V8)が採用されている大きな理由の一つです。
ClearScript に依存している箇所
1.5.8.1 で Microsoft.ClearScript を参照しているのは、ScriptEngine.cs のほか、ServerScriptFile.cs・ServerScriptCsv.cs($ps.file と CSV。エラー通知に ScriptObject を受け取り、戻り値を V8ScriptEngine.Current.Script.Array.from() で JavaScript の配列にしている)、FormulaServerScriptUtilities.cs と Filters/HandleErrorExAttribute.cs(ScriptEngineException の判定)です(ServerScriptFile.cs#L25、ServerScriptCsv.cs#L59-L61)。また $ps などを組み立てる ServerScriptJsLibraries.cs は JavaScript のコードそのものです。別の言語のエンジンを足すときは、これらが改修対象になります。
EnableDateTimeConversion による日付の変換については 日付の扱い を参照してください。
C# スクリプトを使えるようにする(試作)
Microsoft.CodeAnalysis.CSharp.Scripting を使い、パラメータで実行エンジンを切り替えられるようにする試作です。model や context などの基本部分のみの実装で、$ps などの拡張関数は実装していません。
想定する書き方
// 基本的な取得方法
var title = model.Get("Title");
var status = model.Get("Status");
//インデクサ経由での取得
var body = model["Body"];
// 基本的な設定方法
model.Set("Title", "新しいタイトル");
model.Set("Status", 200);
// インデクサーを使用
model["Title"] = "更新されたタイトル";
model["Status"] = 900;System 名前空間に加え、LINQ を使えるよう System.Linq と System.Collections.Generic も使えるようにします。
実装の手順
- NuGet で
Microsoft.CodeAnalysis.CSharp.Scriptingを追加します(依存するコード解析系パッケージもそのまま追加します)。 - パラメータを追加します。
Implem.ParameterAccessor\Parts\Script.csに列挙体ServerScriptEngineTypes(ClearScript/CSharpScript)とプロパティServerScriptEngineType(既定ClearScript)を追加し、Script.jsonに"ServerScriptEngineType": "CSharpScript"を書きます。 - 抽象化レイヤー(
IScriptEngine)を追加し、既存の ClearScript 実装を壊さずに切り替えられるようにします。 - ClearScript 用のラッパー(
ClearScriptEngineWrapper) と C# スクリプト用のラッパー(CSharpScriptEngineWrapper) を作ります。 - ファクトリ(
ScriptEngineFactory) でパラメータに応じてエンジンを選び、既存のScriptEngineクラスはファクトリで作ったエンジンに処理を委譲する形に書き換えます。 ServerScriptUtilities.csでnew ScriptEngine(debug: debug, engineType: Parameters.Script.ServerScriptEngineType)のようにエンジン種別を渡します。- ClearScript 用の組み込み関数群(
ServerScriptJsLibraries.ScriptInit()、model.File.Script()、model.Csv.Script()、ServerScriptJsLibraries.Scripts())を読み込む部分に分岐を入れ、C# スクリプトのときは読み込まないようにします(本番運用するなら、これらを C# に書き換えたものが別途必要です)。
インターフェースは次のとおりです。
using System;
namespace Implem.Pleasanter.Libraries.ServerScripts
{
public interface IScriptEngine : IDisposable
{
void SetContinuationCallback(Func<bool> callback);
void AddHostType(Type type);
void AddHostObject(string itemName, object target);
void Execute(string code, bool debug);
object Evaluate(string code);
}
}CSharpScriptEngineWrapper のポイントは次の 3 点です。コード全文は下の「CSharpScriptEngineWrapper.cs」を開いてください。
CreateScriptOptionsで参照(System、System.Linq、System.Collections.Generic、System.Dynamicなど)とインポートを追加するCreateGlobalsで、AddHostObjectで渡されたオブジェクトをグローバル変数(ScriptGlobals)として公開する。model/saved/columnsはExpandoObjectを扱いやすくするExpandoObjectWrapper(Get/Set/ インデクサ /GetString/GetIntなどを持つ)でラップする- C# スクリプトの実行(
CSharpScript.RunAsync/ContinueWithAsync)は非同期なので、既存インターフェースに合わせて同期メソッドから呼び出す
CSharpScriptEngineWrapper.cs
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
namespace Implem.Pleasanter.Libraries.ServerScripts
{
public class CSharpScriptEngineWrapper : IScriptEngine
{
private readonly Dictionary<string, object> HostObjects = new Dictionary<string, object>();
private readonly HashSet<Type> HostTypes = new HashSet<Type>();
private ScriptState<object> ScriptState;
private Func<bool> ContinuationCallback;
public void SetContinuationCallback(Func<bool> callback)
{
ContinuationCallback = callback;
}
public CSharpScriptEngineWrapper(bool debug)
{
//デバッグまで考えるのは今は見送り
}
public void AddHostType(Type type)
{
if (type != null)
{
HostTypes.Add(type);
}
}
public void AddHostObject(string itemName, object target)
{
if (!string.IsNullOrEmpty(itemName) && target != null)
{
// ExpandoObject の場合はラッパーでラップして使いやすくする
if (target is ExpandoObject expando)
{
HostObjects[itemName] = new ExpandoObjectWrapper(expando);
}
else
{
HostObjects[itemName] = target;
}
}
}
public void Execute(string code, bool debug)
=> ExecuteAsync(code).GetAwaiter().GetResult();
public object Evaluate(string code)
=> EvaluateAsync(code).GetAwaiter().GetResult();
private async Task ExecuteAsync(string code)
{
var globals = CreateGlobals();
var options = CreateScriptOptions();
if (ScriptState == null)
{
ScriptState = await CSharpScript.RunAsync(
code,
options,
globals,
globalsType: typeof(ScriptGlobals));
}
else
{
ScriptState = await ScriptState.ContinueWithAsync(
code,
options);
}
}
private async Task<object> EvaluateAsync(string code)
{
var globals = CreateGlobals();
var options = CreateScriptOptions();
if (ScriptState == null)
{
ScriptState = await CSharpScript.RunAsync(
code,
options,
globals,
globalsType: typeof(ScriptGlobals));
}
else
{
ScriptState = await ScriptState.ContinueWithAsync(
code,
options);
}
return ScriptState.ReturnValue;
}
private ScriptOptions CreateScriptOptions()
{
var options = ScriptOptions.Default
.WithReferences(typeof(object).Assembly)
.WithReferences(typeof(System.Linq.Enumerable).Assembly)
.WithReferences(typeof(System.Collections.Generic.List<>).Assembly)
.WithReferences(typeof(System.Dynamic.ExpandoObject).Assembly)
.WithReferences(typeof(ExpandoObjectWrapper).Assembly)
.WithImports("System")
.WithImports("System.Linq")
.WithImports("System.Collections.Generic")
.WithImports("System.Dynamic")
.WithImports("Implem.Pleasanter.Libraries.ServerScripts");
foreach (var type in HostTypes)
{
options = options.WithReferences(type.Assembly);
if (!string.IsNullOrEmpty(type.Namespace))
{
options = options.WithImports(type.Namespace);
}
}
return options;
}
private ScriptGlobals CreateGlobals()
{
var globals = new ScriptGlobals();
foreach (var obj in HostObjects)
{
globals.SetProperty(obj.Key, obj.Value);
}
return globals;
}
public void Dispose()
{
HostObjects?.Clear();
HostTypes?.Clear();
ScriptState = null;
}
/// <summary>
/// C# Script用のグローバル変数を保持する静的クラス
/// </summary>
public class ScriptGlobals
{
private readonly Dictionary<string, object> Properties = new Dictionary<string, object>();
public void SetProperty(string name, object value)
{
Properties[name] = value;
}
public object GetProperty(string name)
{
return Properties.TryGetValue(name, out var value) ? value : null;
}
// ExpandoObject系のプロパティは型を明示
public ExpandoObjectWrapper model => GetProperty("model") as ExpandoObjectWrapper;
public ExpandoObjectWrapper saved => GetProperty("saved") as ExpandoObjectWrapper;
public ExpandoObjectWrapper columns => GetProperty("columns") as ExpandoObjectWrapper;
// その他のプロパティはobject型のまま
public object context => GetProperty("context");
public object grid => GetProperty("grid");
public object depts => GetProperty("depts");
public object groups => GetProperty("groups");
public object users => GetProperty("users");
public object siteSettings => GetProperty("siteSettings");
public object view => GetProperty("view");
public object items => GetProperty("items");
public object hidden => GetProperty("hidden");
public object responses => GetProperty("responses");
public object elements => GetProperty("elements");
public object extendedSql => GetProperty("extendedSql");
public object notifications => GetProperty("notifications");
public object httpClient => GetProperty("httpClient");
public object utilities => GetProperty("utilities");
public object logs => GetProperty("logs");
public object _file_cs => GetProperty("_file_cs");
public object _csv_cs => GetProperty("_csv_cs");
}
/// <summary>
/// ExpandoObject をC# Script から扱いやすくするラッパークラス
/// </summary>
public class ExpandoObjectWrapper
{
private readonly ExpandoObject InnerObject;
private readonly IDictionary<string, object> Dictionary;
public ExpandoObjectWrapper(ExpandoObject obj)
{
InnerObject = obj;
Dictionary = (IDictionary<string, object>)obj;
}
// プロパティ値を取得(型安全版)
public T Get<T>(string propertyName)
{
if (Dictionary.TryGetValue(propertyName, out var value))
{
if (value == null) return default(T);
if (value is T typedValue) return typedValue;
try
{
return (T)Convert.ChangeType(value, typeof(T));
}
catch
{
return default(T);
}
}
return default(T);
}
// プロパティ値を取得(object版)
public object Get(string propertyName)
{
return Dictionary.TryGetValue(propertyName, out var value) ? value : null;
}
// プロパティ値を設定
public void Set(string propertyName, object value)
{
Dictionary[propertyName] = value;
}
// プロパティの存在確認
public bool Has(string propertyName)
{
return Dictionary.ContainsKey(propertyName);
}
// インデクサーでのアクセス
public object this[string propertyName]
{
get => Get(propertyName);
set => Set(propertyName, value);
}
// 便利メソッド
public string GetString(string propertyName) => Get(propertyName)?.ToString() ?? string.Empty;
public int GetInt(string propertyName) => Get<int>(propertyName);
public decimal GetDecimal(string propertyName) => Get<decimal>(propertyName);
public bool GetBool(string propertyName) => Get<bool>(propertyName);
public DateTime GetDateTime(string propertyName) => Get<DateTime>(propertyName);
// すべてのキーを取得
public IEnumerable<string> Keys() => Dictionary.Keys;
// 元の ExpandoObject を取得
public ExpandoObject GetInnerObject() => InnerObject;
}
}
}動作確認
条件「画面表示の前」に次のスクリプトを書き、タイトルに現在日時がセットされることを確認しています。
model.Set("Title", System.DateTime.Now);本番環境で使うには、ClearScript 用に用意されている組み込み関数群の移植などがまだ必要です。
また、C# スクリプトはホストと同じ .NET ランタイムで動くため、System.IO.File などを完全修飾名やリフレクションで呼べてしまい、V8 のようなサンドボックスは作れません。一般のユーザーがスクリプトを書く環境に出してよいかの評価は C# スクリプト(Roslyn)をサーバースクリプトに使う を参照してください。
Python を使えるか(IronPython の調査)
候補ライブラリ
| 項目 | IronPython 3 | Python.NET (pythonnet) |
|---|---|---|
| 方式 | .NET 純粋実装の Python | CPython を .NET に埋め込み |
| NuGet パッケージ | IronPython (3.4.x) | pythonnet (3.0.x) |
| Python 互換性 | Python 3.4 相当 | CPython 完全互換 |
| 外部依存 | なし(NuGet のみ) | CPython のインストールが必須 |
| .NET 連携 | DLR ベースで直接連携 | 型変換が必要 |
| NumPy / Pandas | 利用不可 | 利用可能 |
| サンドボックス | 4 層ロックダウンで実現可能 | 制限が困難 |
| スレッドセーフ | スコープ分離で安全 | GIL による制約あり |
| Docker 対応 | 追加インストール不要 | Python ランタイム必要 |
| ライセンス | Apache 2.0 | MIT |
サーバースクリプトに使うなら IronPython 3 が適している、というのが調査の結論です。
- NuGet パッケージの追加だけで完結し、CPython のインストールや Docker イメージの変更が要りません。
- DLR 上で動作し、
scope.SetVariable()で ClearScript のAddHostObjectと同じように C# オブジェクトを公開できます。ExpandoObjectを Python から直接操作できるため、既存のホストオブジェクトを流用できます。 - サーバースクリプトの用途(値操作、条件分岐、API 呼び出し)に NumPy などの科学計算ライブラリは不要です。
# サーバスクリプト(Python版のイメージ)
if context.Action == 'create':
model.ClassA = f"作成者: {context.UserName}"
model.NumA = model.NumB * 1.1
import json
data = json.loads(model.DescriptionA)
model.ClassB = data.get('category', '未分類')
import datetime
today = datetime.date.today()
model.DateA = today.isoformat()課題:そのままでは OS に触れてしまう
IronPython は .NET 上で動くため、何も制限しなければ .NET の全機能にアクセスできてしまいます(import clr から System.Diagnostics.Process でコマンド実行、System.IO.File でファイル読み取り、os.system など)。
図を読み込み中…
V8 が「API が存在しないから安全(原理的保証)」なのに対し、IronPython は「API はあるが封鎖して安全にする(実装的保証)」という逆のアプローチになります。
4 層防御のサンドボックス
1 つの仕組みに頼らず、4 つの防御レイヤーを重ねます(多層防御)。
図を読み込み中…
| レイヤー | 防御対象 | 方法 |
|---|---|---|
| Layer 1 | 危険な組み込み関数 | __builtins__ をホワイトリストの辞書に差し替える(open、exec、eval、compile、__import__、exit / quit、globals / locals、breakpoint、input などを除去) |
| Layer 2 | 危険なモジュール | カスタム __import__ で math、json、datetime、re、decimal、string、collections、itertools、functools、copy などだけを許可 |
| Layer 3 | 外部 .py ファイル | engine.SetSearchPaths(new List<string>()) で検索パスを空にする |
| Layer 4 | .NET アセンブリ | clr、System、Microsoft、os、subprocess などを sys.modules[mod] = None で封鎖(モジュールポイズニング) |
組み合わせる理由は次のとおりです。
SearchPathsを空にしても、osやsysのようなビルトインモジュール(C# 実装)はファイルシステムを経由せず読み込まれるため防げません。Layer 2 のカスタム__import__と組み合わせます。sys.modulesの操作だけでは、del sys.modules['os']で消してから再インポートする回避が可能です。sys自体へのアクセスも Layer 2 で制限する必要があります。- .NET Framework 時代の Code Access Security(CAS)は .NET Core 以降で廃止されています。プリザンターは .NET 10 をターゲットにしているため、サンドボックスはすべてアプリケーションレベルで実装します。
適用後にスクリプトから見えるのは、ホストオブジェクト(model、context、items、columns など)、副作用のない組み込み関数(len、str、int、list、range、sorted など)、純粋演算の標準モジュール(math、json、datetime、re など)だけになります。
| テスト | 期待結果 |
|---|---|
import os / import subprocess / import clr / import System | ImportError |
open('/etc/passwd') / exec('import os') / eval('1+1') / exit() | NameError |
import math; math.sqrt(16) | 4.0 |
import json; json.dumps({"a": 1}) | '{"a": 1}' |
model.ClassA = 'test' | 正常に設定される |
items.Get(123) | 正常に API 呼び出し |
タイムアウト制御
ClearScript では ContinuationCallback で実行中のタイムアウトを確認しています。IronPython では sys.settrace のコールバックで期限を確認し、超えたら TimeoutError を投げることで同じことを実現します。ステートメントごとに呼ばれるため、無限ループの検出にも有効です。
注意が必要なエスケープ手法
| リスク | 説明 | 対策 |
|---|---|---|
object.__subclasses__() | 全サブクラスから危険な型を探索 | type の除外 + __subclasses__ アクセス制限 |
__class__.__mro__ | メソッド解決順序を辿ってエスケープ | メタクラス属性のブロック |
ホストオブジェクトの .GetType() | C# オブジェクトからリフレクション | SafeHostObject ラッパーの検討 |
CPU 枯渇(while True: pass) | 無限ループ | sys.settrace + タイムアウト |
メモリ枯渇([0] * 10**9) | 大量メモリ割り当て | Docker のメモリ制限 |
実装する場合の構成
IScriptEngineインターフェースを抽出し、既存の V8 ラッパーとPythonScriptEngineをScriptEngineFactoryで切り替えます。
図を読み込み中…
- スクリプトごとに言語を選べるようにし、サイト単位の既定言語も設定できるようにします。
Languageが未指定(null)なら JavaScript として動くので、既存のスクリプトとの後方互換性は保たれます。
図を読み込み中…
- 導入は段階的に進めます。
| Phase | 内容 |
|---|---|
| Phase 1 | IScriptEngine インターフェースの抽出、既存コードのリファクタリング |
| Phase 2 | PythonScriptEngine の実装(IronPython 統合 + サンドボックス) |
| Phase 3 | UI 対応(言語選択ドロップダウン、コードエディタの切り替え) |
| Phase 4 | テスト(既存 JS の回帰 + Python + サンドボックス突破テスト) |
調査の評価としては実現可能性が高いものの、最大の課題はサンドボックスです。V8 のような原理的保証ではないため、テストの徹底と継続的な脆弱性検証が欠かせません。言語の切り替えのデータ構造、同じ条件に 2 つの言語があるときの実行順、封鎖するモジュールの一覧などは サーバースクリプトを Python で書けるようにする にまとめています。