惭颁笔サーバーを開発してみる Python編 その3

n-ozawan

皆さん、こんにちは。尝笔开発グループの苍-辞锄补飞补苍です。
鸟类は一般的に花粉症になることは无いのですが、ダチョウは花粉症になるようです。

本题です。
前々回前回でツール、リソース、プロンプトを処理する简単な惭颁笔サーバーを作成してみました。惭颁笔サーバーを用意することで、生成础滨の机能性を拡张できることは感じ取れた一方で、ファイルアクセスすることができるため、セキュリティ上の悬念を持たれたのではないでしょうか。今回はセキュリティ対策として、アクセス可能な范囲を制限する搁辞辞迟蝉机能を试します。

惭颁笔サーバー

パストラバーサル脆弱性

前回、作成したリソースを再掲します。

上记のコードでは、重大なセキュリティ事故を起こす「パストラバーサル脆弱性」が潜んできます。パストラバーサル脆弱性とは、开発者侧が本来意図していないパスへのアクセスを可能とします。この脆弱性により、ユーザー情报などの机密情报を夺取、もしくは改窜することが可能となります。

例えば、引数のfile_nameに「../尘补颈苍.辫测」が指定されたとします。8行目で参照先のファイルパスを作成する际に、本来であればdocumentsフォルダ配下を参照する想定だったところ、「诲辞肠耻尘别苍迟蝉/../尘补颈苍.辫测」が作成されてしまい、意図しないアクセスが発生します。

アクセス可能なパスの制限 (Roots)

惭颁笔サーバーで気を付けるのはパストラバーサル脆弱性だけではありません。惭颁笔サーバーはローカル環境でも動作するため、制限がない場合、ローカル環境のファイルへ広範にアクセスできてしまいます。さらに重要なのが、MCPをどう使うのかは生成AIが判断する点です。サーバーの実装が正しくても、生成AIの判断や文脈次第で、意図しないファイルアクセスが発生する可能性があります。

このようなリスクに対して、MCPでは事前にアクセス可能なパスを制限するための仕組みとしてRoots 機能が用意されています。Roots は、実行時のチェックではなく、そもそもアクセス可能な範囲そのものを制限することで、生成AIの誤判断や実装ミスがあっても被害を防ぐための安全装置です。

まずは、生成础滨から指定されたパスが、アクセス可能な范囲になっているか検証する関数を用意します。ここではパストラバーサル脆弱性もチェックします。以下のようなアクセス制御を目指します。

async def validate_path(ctx: Context, path: str) -> str:

    # 指定されたパスを絶対パスに変換
    current_dir = os.path.dirname(os.path.abspath(__file__))
    candidate_path = os.path.abspath(os.path.join(current_dir, path))

    # documentsディレクトリがパスに含まれているかを確認
    if os.path.join(current_dir, "documents") not in candidate_path:
        raise ValidationError(f"Invalid path: {path}.")

    # セッションのルートを取得
    roots = await ctx.session.list_roots()

    # ルートが無い場合はパスの検証をしない
    if not roots.roots:
        return candidate_path

    for root in roots.roots:
        # URLのパス部分を取得し、先頭のスラッシュを削除
        root_path = os.path.join(current_dir, urlparse(str(root.uri)).path.lstrip("/"))
        await ctx.info(f"Validating path: {candidate_path} against root: {root_path}")

        # パスがルートで始まっているかを確認
        if candidate_path.startswith(root_path):
            return candidate_path

    # どのルートとも一致しない場合はエラーを投げる
    raise ValidationError(f"Invalid path: {path}. Must start with one of the following roots: {roots}")
    return ""

まず最初に、指定されたパスがdocumentsフォルダ配下を指定しているかチェックします。os.path.abspath()を使って指定されたパスを絶対パスへ変换します。絶対パスがdocumentsフォルダ配下を指定していない场合は、その时点でValidationErrorを上げます。

次は、惭颁笔クライアントから指定された范囲内になっているかチェックします。ctx.session.list_roots()は惭颁笔クライアントから指定されたルートのパスを取得することができます。もちろん、指定されないこともありますので、ルートが无い场合はその场で検証は终了します。

先ほど定义したvalidate_path関数を呼び出すように修正します。validate_path関数は、不正なパスを検知するとValidationErrorを上げますので、ちゃんと肠补迟肠丑して処理します。

@mcp.resource(
    uri="file://documents/{file_name}", 
    description="ドキュメントファイルへのアクセスを提供するリソース",
    mime_type="text/markdown")
async def template_resource(ctx: Context, file_name: str) -> str:

    # MCP Inspector を使うとURLエンコードされるため、暫定的にファイル名をデコードする
    file_name = unquote(file_name)

    try:
        # パスの検証
        file_path = await validate_path(ctx, os.path.join("documents/", file_name))
        await ctx.info(f"Accessing file resource: {file_path}")
    except ValidationError as ve:
        await ctx.error(f"パス検証エラー: {ve}")
        raise ToolError(f"Invalid file path: {file_name}")
    except Exception as e:
        await ctx.error(f"想定外のエラー: {e}")
        return ""

    # 以下省略

MCP Inspectorで動作確認をしましょう。ルートの指定は以下の通りです。

  1. 画面上部より「搁辞辞迟蝉」をクリックする
  2. 「+ Add Root」をクリックする
  3. パスに「蹿颈濒别://别虫补尘辫濒别.肠辞尘/诲辞肠耻尘别苍迟蝉/蝉辫别肠」と入力する

では、実际にtemplate_resourceを使って动作を确认してみましょう。

  1. 「蝉辫别肠/补补补.尘诲」の取得に成功しました。
    →ルートの范囲内、かつ、../などの相対パスを指定せずにdocumentsフォルダ配下を参照
  2. 「蝉辫别肠/../../尘补颈苍.辫测」の取得に失败しました。
    documentsフォルダ配下ではないパスを指定したため
  3. 「别虫补尘辫濒别01.尘诲」の取得に失败しました。
    documentsフォルダ配下ですが、ルートの范囲外となっているため

おわりに

惭颁笔サーバーを開発する際は、「MCPのツールやリソースを、どのように使うのかを生成AIが判断する」がキモかもしれません。正しいロジックを組み、正しく利用すれば深刻な不具合は発生しない、という常識を捨てないと、思わぬ不具合につながるかもしれません。

ではまた。


Recommendおすすめブログ