サーバースクリプトを Python で書けるようにする
1.5.8.1 のプリザンターでは、サーバースクリプトは ClearScript(V8)で動く JavaScript だけです。このページは、サーバースクリプトに Python を追加する本体改修の設計メモで、本体の標準機能ではありません。調査は 1.5.1.0 を対象に行い、前提にした現行実装は 1.5.8.1 のソースで確かめ直しています。
概要と試作の流れは C# スクリプト・Python は使えるか にあります。このページはその詳細設計です。
前提にした現行実装
- エンジンは
ScriptEngineクラスで、V8ScriptEngineを薄くラップしたものです。リクエストのたびに作って破棄します(ScriptEngine.cs、ServerScriptUtilities.cs#L1217)。 - スクリプトに見えるのは、
AddHostObjectで渡す 19 個のオブジェクト(context・grid・model・saved・depts・groups・users・columns・siteSettings・view・items・hidden・responses・elements・extendedSql・notifications・httpClient・utilities・logs)と、AddHostTypeで渡すNewtonsoft.Json.JsonConvertだけです(ServerScriptUtilities.cs#L1217-L1270)。 - 共有スクリプトと対象スクリプトは、改行で連結して 1 回で実行されます。
ServerScriptに言語を表すプロパティはありません(ServerScript.cs#L7-L35)。サイトのダイアログのコードエディタもdataLang: "javascript"固定です(SiteUtilities.cs#L17983)。
ClearScript に直接依存している箇所
Microsoft.ClearScript を参照しているのは次の 5 ファイルです。別のエンジンを足すときはここが改修対象になります。
| ファイル | 依存内容 |
|---|---|
Libraries/ServerScripts/ScriptEngine.cs | V8ScriptEngine・V8ScriptEngineFlags |
Libraries/ServerScripts/ServerScriptFile.cs | 引数の ScriptObject callback、戻り値の V8ScriptEngine.Current.Script.Array.from()(#L25、#L98) |
Libraries/ServerScripts/ServerScriptCsv.cs | 同上(#L14、#L59-L61) |
Libraries/ServerScripts/FormulaServerScriptUtilities.cs | ScriptEngineException の捕捉(#L98) |
Filters/HandleErrorExAttribute.cs | ScriptEngineException の判定(#L49) |
加えて ServerScriptJsLibraries.cs は $ps などを組み立てる JavaScript のコードそのもので、Python 用には別に用意する必要があります。
設計原則:値操作だけを許す
V8 はファイル・プロセス・ネットワーク・環境変数を操作する API をエンジン自体が持たないため、AddHostObject で渡したもの以外には原理的に触れません。IronPython は .NET の DLR 上で動くため、何もしなければ import clr から .NET の全機能に届きます。
そこで Python 版の目的を「JavaScript と同じこと(ホストオブジェクト経由の値操作)を Python の構文で書けるようにする」に限定し、Python の汎用機能(ファイル・ネットワーク・OS 操作)は提供しません。
| 許可する | 例 |
|---|---|
| ホストオブジェクトの読み書き・メソッド呼び出し | model.ClassA = 'test'、items.Get(123)、context.ErrorData.Type = 1 |
| Python の基本構文 | 条件分岐・ループ・内包表記・文字列操作・算術 |
| 副作用のない組み込み関数 | len、str、int、list、range、sorted など |
| 純粋演算の標準モジュール | math、json、datetime、re、decimal、collections など |
| 禁止する | 例 |
|---|---|
| .NET 相互運用 | import clr、import System |
| OS・プロセス | os、subprocess、ctypes、threading、multiprocessing |
| ファイル・ネットワーク | open()、io、pathlib、socket、urllib、http |
| 動的なコード実行・読み込み | exec、eval、compile、__import__、importlib |
| 任意コード実行につながる直列化 | pickle、shelve、marshal |
V8 の「原理的保証」に対して、IronPython は封鎖による「実装的保証」になります。この差がこの改修の最大のリスクです。
ライブラリの選定
| 項目 | IronPython 3 | Python.NET(pythonnet) |
|---|---|---|
| 方式 | .NET で実装した Python | CPython を .NET プロセスに埋め込む |
| NuGet | IronPython(3.4.x) | pythonnet(3.0.x) |
| 言語の互換性 | Python 3.4 相当(一部 3.6 以降の構文も可) | CPython と完全互換 |
| 外部依存 | なし | サーバーに CPython のインストールが必要 |
| .NET オブジェクトとの連携 | scope.SetVariable() でそのまま渡せる | 変換が必要 |
| NumPy などの C 拡張 | 使えない | 使える |
| サンドボックス | 封鎖すれば実現できる | 制限が難しい |
| スレッド | スコープを分ければ安全 | GIL の制約がある |
| ライセンス | Apache 2.0 | MIT |
IronPython 3 を採用します。NuGet の追加だけで済み(Docker イメージも変えなくてよい)、ClearScript の AddHostObject と同じ感覚で C# のオブジェクトを渡せ、ExpandoObject を Python から model.ClassA の形で読み書きできます。サーバースクリプトの用途に NumPy などは要りません。
改修の構成
IScriptEngine を抽出し、既存の V8 ラッパーと新しい PythonScriptEngine をファクトリで切り替えます。
図を読み込み中…
| 層 | ファイル | 内容 |
|---|---|---|
| エンジン | ScriptEngine.cs | IScriptEngine を抽出し、既存クラスを V8 用の実装にする |
| エンジン | 新規 IScriptEngine.cs・PythonScriptEngine.cs・ScriptEngineFactory.cs | インターフェース、IronPython の実装、言語に応じた生成 |
| データ | ServerScript.cs | Language(int?)を追加 |
| データ | SiteSettings.cs | サイトの既定言語 ServerScriptLanguage(int?)を追加 |
| 実行 | ServerScriptUtilities.cs | Execute でファクトリからエンジンを作る。言語ごとに分けて実行する |
| 実行 | 新規 ServerScriptPyLibraries.cs | $ps 相当の Python 用ヘルパー |
| 依存の解消 | ServerScriptFile.cs・ServerScriptCsv.cs | ScriptObject のコールバックと JS 配列の戻り値をやめる |
| 画面 | SiteUtilities.cs | ダイアログに言語の選択を追加し、コードエディタの dataLang を切り替える。サイトの既定言語の設定も追加 |
FormulaServerScriptUtilities(計算式のスクリプト)とバックグラウンドサーバースクリプト(TenantUtilities.cs)は、最初は JavaScript のままにします。
言語の決め方
図を読み込み中…
Language を int? にするのは、既存の設定 JSON に値が無い(null)とき JavaScript として動かすためです。0 が JavaScript、1 が Python です。
{
"ServerScriptLanguage": 0,
"ServerScripts": [
{ "Id": 1, "Title": "BeforeCreate (JS)", "Body": "model.ClassA = 'test';", "BeforeCreate": true, "Language": 0 },
{ "Id": 2, "Title": "BeforeUpdate (Python)", "Body": "model.ClassA = 'updated'", "BeforeUpdate": true, "Language": 1 }
]
}同じ条件に 2 つの言語があるとき
現行はスクリプトを連結して 1 回で実行するので、言語が混ざると 1 つのエンジンでは動きません。候補は 3 つです。
| 方式 | 内容 | 評価 |
|---|---|---|
| 言語ごとに順に実行 | JavaScript をまとめて実行し、次に Python をまとめて実行する | 採用(現行の「連結して一度に実行」に近い) |
| 登録順に実行 | Id 順に言語を切り替えながら実行する | 見送り |
| 混在を禁止 | 同じ条件で異なる言語を登録できなくする | 次点 |
どちらのエンジンも同じ ServerScriptModel のオブジェクトを受け取り、最後に SetValues() で画面・レコードに反映します。
File・CSV の ClearScript 依存を外す
ServerScriptFile・ServerScriptCsv のメソッドは、エラー通知に ClearScript の ScriptObject を受け取り、戻り値を V8ScriptEngine.Current で JS 配列にしています。
- 方式 A(採用): コールバックを
Action<string, string>などの .NET のデリゲートに変え、JavaScript 側・Python 側それぞれのラッパーで例外に変換する。 - 方式 B: 戻り値を
List<List<string>>などの .NET 型にそろえ、各言語のラッパーで配列に変換する。
PythonScriptEngine の要点
- コンストラクタで
Python.CreateEngine()し、ユーザーのスクリプトより前にサンドボックスを適用してからスコープを作ります。 AddHostObjectはscope.SetVariable(name, target)、ExecuteはSourceCodeKind.Statements、EvaluateはSourceCodeKind.Expressionで実行します。Disposeでengine.Runtime.Shutdown()します。
# 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', '未分類')タイムアウト
ClearScript は ContinuationCallback で V8 の実行に割り込んでタイムアウトを判定しています。IronPython では次の方式を比べ、sys.settrace を採用します。
| 方式 | 評価 |
|---|---|
別スレッドからエンジンを止める(Thread.Abort など) | 危険。.NET では Thread.Abort が使えない |
sys.settrace で文ごとにコールバックし、期限を過ぎたら TimeoutError を投げる | 採用。無限ループも止められる |
Task.Run と CancellationToken | CPU だけを使うループは止められない |
トレース関数は、サンドボックスで sys を封じる前に設定しておく必要があります。
サンドボックス(4 層の防御)
1 つの仕組みでは抜け道が残るため、4 つを重ねます。
図を読み込み中…
Layer 1:組み込み関数の制限
スコープの __builtins__ を、許可した関数だけの辞書(PythonDictionary)に差し替えます。IronPython 3 では exec・eval は文ではなく関数なので、辞書から外せば使えなくなります。
| 外すもの | 理由 |
|---|---|
__import__・exec・eval・compile | 任意のコード実行・モジュール読み込み(__import__ は Layer 2 の版に置き換える) |
open・input | ファイル I/O・標準入力 |
exit・quit・breakpoint・help | プロセス終了・デバッガ・ヘルプ経由の情報 |
globals・locals・vars | スコープの辞書からサンドボックスの設定を書き換えられる |
getattr・setattr・delattr・type | 属性の動的アクセスやメタクラス操作で制限を回避できる(外すのを推奨) |
残すのは型変換(int・str・list・dict など)、数値(abs・round・min・max・sum など)、反復(range・len・enumerate・zip・map・filter・sorted など)、文字列(chr・ord・format など)、isinstance・hasattr など安全なもの、例外クラス、True・False・None です。print はログ用に残すかを選べます。
Layer 2:import の制御
__import__ を、許可リストに無いモジュールで ImportError を投げる関数に差し替えます。この方式が一番効果があり、ビルトインモジュールにもファイルのモジュールにも効きます。
許可するモジュールの例は json・math・cmath・datetime・re・collections・itertools・functools・operator・string・decimal・fractions・copy・enum・typing・abc・dataclasses・textwrap・unicodedata・base64・hashlib・uuid・random・statistics・bisect・heapq・calendar などです。IronPython 自体が内部で使う _collections・_functools・_sre・codecs なども必要になります。
| 方式 | ビルトインモジュール | ファイルのモジュール | 回避されにくさ |
|---|---|---|---|
カスタム __import__ | 防げる | 防げる | 高い |
PlatformAdaptationLayer のサブクラス | 防げない | 防げる | 高い |
CreateEngine のオプション | 防げない | 防げない | - |
sys.modules の操作 | 防げる | 防げる | 低い(del sys.modules['os'] で外せる) |
CreateEngine のオプション(Debug・Frames・LightweightScopes など)にサンドボックスの機能は無く、CLR 相互運用を切る公式の NoClr のようなオプションもありません。
Layer 3:ファイルからの読み込みを塞ぐ
engine.SetSearchPaths() を空にし、さらに PlatformAdaptationLayer のサブクラス(FileExists が常に false、OpenInputFileStream が例外)を使うと、.py ファイルの読み込みを止められます。ただし os のようなビルトインモジュール(C# 実装)はファイルシステムを通らずに読み込まれるので、Layer 2 と組み合わせる必要があります。
Layer 4:.NET 相互運用とモジュールの封鎖
sys.modules['clr'] などを None にしておくと、import が ImportError になります(モジュールポイズニング)。
| 区分 | 封鎖するモジュール |
|---|---|
| 必須 | os・subprocess・sys・ctypes・socket・importlib・shutil・io |
| 強く推奨 | multiprocessing・threading・_thread・signal・tempfile・pathlib・glob・pickle・shelve・dbm・sqlite3 |
| 推奨 | http・urllib・webbrowser・ftplib・smtplib・poplib・imaplib・xml・zipfile・tarfile・gzip・bz2・lzma |
| IronPython 固有 | clr・System・Microsoft・nt・posix・_io・_socket・_ssl |
| 調査・デバッグ用 | inspect・dis・code・codeop・pdb・trace など |
clr が読み込めると clr.AddReference() で任意のアセンブリを読み込め、System.Diagnostics.Process や System.IO.File に届きます。.NET Framework 時代の Code Access Security(CAS)は .NET Core 以降で廃止されており、.NET 10 のプリザンターではランタイムに任せられるサンドボックスはありません。すべてアプリケーション側で作ります。
封鎖したいアクセスの経路
図を読み込み中…
ホストオブジェクトからの抜け道
clr を封じても、注入した C# のオブジェクトの .GetType() からリフレクションに進める可能性があります。対策として、注入時に Process・File・Assembly などの型を渡していないか確認する、DynamicObject を継承したラッパー(GetType などを拒否する)で包む、が考えられます。ラッパーは性能に影響するので、ExpandoObject ではない .NET の型のインスタンス(httpClient など)を渡すときに限って検討します。
残るリスク
| リスク | 内容 | 対策 |
|---|---|---|
object.__subclasses__() | ().__class__.__bases__[0].__subclasses__() で全サブクラスから危険な型を探す | type・getattr を外し、__subclasses__・__bases__・__mro__ へのアクセスを制限する。IronPython では返る型が CPython と違うが、.NET の型が含まれうるので対策は必要 |
| CPU の枯渇 | while True: pass | sys.settrace によるタイムアウト |
| メモリの枯渇 | [0] * 10**9 | IronPython にメモリ制限の機能は無い。コンテナのメモリ上限、GC.GetTotalMemory() の監視、別プロセスでの実行 |
確認するテスト
| スクリプト | 期待結果 |
|---|---|
import os・import subprocess・import socket・import pickle | ImportError |
import clr・import System・clr.AddReference(...) 経由の Process | ImportError |
import importlib; importlib.import_module('os') | ImportError |
open(...)・exec(...)・eval(...)・compile(...)・exit() | NameError などの例外 |
import math; math.sqrt(16) | 4.0 |
import json; json.dumps({'a': 1}) | 正常 |
model.ClassA = 'test'・items.Get(123) | 正常 |
__subclasses__() を使ったエスケープ | 失敗すること |
本番に出す前に、IronPython 3.4.x で上のテストを全部実行して挙動を確かめること、コンテナのメモリ上限とスクリプトのエラーログを用意することが前提です。
評価
| 項目 | 内容 |
|---|---|
| 実現可能性 | 高い。既存の ScriptEngine と同じ形で実装できる |
| 後方互換 | Language が null なら JavaScript。既存データに影響しない |
| CodeDefiner | 言語の判定は実行時なので、自動生成のテンプレートは変えなくてよい |
| 主なリスク | サンドボックスが実装的保証であること。テストと脆弱性の継続的な確認が要る |
| フェーズ | 内容 | 工数の目安(人日) |
|---|---|---|
| 1. 基盤 | IScriptEngine の抽出、V8 ラッパーへの改名、ファクトリ(動作は変わらない) | 2.5 |
| 2. エンジン | PythonScriptEngine、タイムアウト、サンドボックス、Language、Execute の言語分岐 | 8 |
| 3. 画面と依存の解消 | 言語の選択、エディタの切り替え、サイトの既定言語、File・CSV の ClearScript 依存除去、Python 用ヘルパー | 5 |
| 4. テスト | JavaScript の回帰、Python、サンドボックス突破 | 5 |
| 将来 | 計算式・バックグラウンドサーバースクリプトの Python 対応、サンプル集 | - |
合計は約 20.5 人日(調査時の概算)です。