Claude Media
Memory toolのパストラバーサル対策 — ../secrets.envを防ぐ実装

Memory toolのパストラバーサル対策 — ../secrets.envを防ぐ実装

memory toolのファイル操作はクライアント側の実装が実行します。/memories/../../secrets.envのような攻撃とURLエンコード済みの迂回を防ぐ検証を、6コマンド全部にかける実装手順です。

はじめに — パスの検証は誰の責任か

memory toolは、Claudeが生成したview・create・str_replace・insert・delete・renameというコマンドを、開発者側の実装が実際のファイル操作として実行する仕組みです。Anthropicのサーバーはファイルシステムに触れません。つまり、/memories/../../secrets.envのようなpathが渡されてきたときにそれを弾くかどうかは、実装側の責任です。公式ドキュメントの「セキュリティ上の考慮事項」も、アプリケーションがClaudeの要求するすべてのファイル操作を実行するため、これらの安全対策は実装側の責任だと書いています。

/memoriesはClaudeに見せる仮想のプレフィックスで、実際の保存先への対応づけはハンドラーが決めます。ユーザーごとのディレクトリでも、データベースのキーでも構いません。保存先を実ファイルシステムに置いた構成で、/memoriesの外に.envのようなファイルがあれば、検証の抜けはそのまま読み書きの抜けになります。

この記事では、/memoriesの外に出るパスを拒否する検証を、6コマンドすべてに一貫してかける実装を組み立てます。Pythonで動かした検証コードの実測結果も載せます。

前提: Python 3.9以降(Path.is_relative_to()を使用)。BetaLocalFilesystemMemoryToolのような組み込みの実装を使わず、BetaAbstractMemoryToolを自前でサブクラス化してファイル操作を書くケースを想定します。自前実装が必要になるのは、ファイルをローカルディスクではなくデータベースやオブジェクトストレージに保存したい場合や、viewの結果をアプリケーション独自のログに残したい場合です。

検証は3段で組む

公式ドキュメントが挙げる安全対策は5点あります。そのうちこの記事では、/memoriesで始まることの確認、正規形への解決と範囲内の確認、../や..\の拒否、URLエンコードへの注意の4点を扱います。これらを1つの関数にまとめ、次の順で判定します。

手順

パス検証の判定順

  1. 1

    トラバーサルの兆候を文字列で落とす

    NULバイトと、..だけで成り立つパス区切りの要素を拒否します。URLデコード後の文字列も同じ検査にかけます。

  2. 2

    /memories配下かを区切り込みで確認する

    /memoriesとの完全一致、または/memories/で始まる場合だけ通します。

  3. 3

    実体のパスに解決して範囲内を最終確認する

    resolve()でシンボリックリンクまで解決し、is_relative_to()でルートの配下にとどまっているかを見ます。

ステップ1: すべてのパスが/memories配下にあることを確認する

最初の防御は文字列としての前方一致チェックです。commandに付随するpath(renameだけはold_pathとnew_path)が、必ず/memoriesから始まることを確認します。

MEMORY_ROOT_PREFIX = "/memories"
 
def check_prefix(raw_path: str) -> None:
    if not raw_path.startswith(MEMORY_ROOT_PREFIX):
        raise ValueError(f"Path must start with /memories: {raw_path}")

これだけでは不十分です。/memories/../../secrets.envは文字列としては/memoriesで始まっているため、このチェックだけでは素通りしてしまいます。前方一致は必要条件であって十分条件ではありません。

さらに、raw_path.startswith("/memories")という書き方自体にも罠があります。これは/memoriesX/notes.txtのような、/memoriesと同じ階層にある別のディレクトリ名まで文字列として一致させてしまいます。区切りの/を含めて/memories/で始まるか、ルート自体を表す/memoriesと完全一致するかを確認しないと、兄弟ディレクトリへのアクセスを誤って通してしまいます。

ステップ2: 要素単位で検査し、解決してから比較する

../のような相対参照は、文字列比較ではなく実際のパス解決を通してから判定します。Pythonのpathlib.Path.resolve()は、..を含むパスをシンボリックリンクも解決したうえで絶対パスに正規化します。正規化後のパスがメモリのルートの配下にとどまっているかを、is_relative_to()で確認します。

文字列段の検査には、".." in decodedのような部分文字列の検索を使わないほうが安全です。この書き方だと、notes..txtのような正当なファイル名まで拒否します。区切り文字で割った要素のどれかが、ちょうど..であるかを見ます。

URLエンコードされたトラバーサルは、%2e%2e%2fが../のパーセントエンコード表現です。公式ドキュメントも注意を促しています。素の文字列では..が見えないため、元の文字列とurllib.parse.unquote()でデコードした文字列の両方を検査にかけます。解決に使うのは元の文字列です。デコード結果を使うと、a%20b.txtというファイル名が勝手にa b.txtへ書き換わるためです。

import re
from pathlib import Path, PurePosixPath
from urllib.parse import unquote
 
MEMORY_ROOT = Path("/memories").resolve()
 
 
class PathTraversalError(ValueError):
    pass
 
 
def has_dotdot(s: str) -> bool:
    # / と \ の両方で割り、要素がちょうど .. のものを探す
    return ".." in re.split(r"[/\\]", s)
 
 
def resolve_memory_path(raw_path: str) -> Path:
    if "\x00" in raw_path:
        raise PathTraversalError("Invalid path: NUL byte")
 
    # %2e%2e%2f のようなURLエンコード済みの ../ も、デコード後の文字列で検出する
    if has_dotdot(raw_path) or has_dotdot(unquote(raw_path)):
        raise PathTraversalError(f"Invalid path: {raw_path}")
 
    if raw_path != "/memories" and not raw_path.startswith("/memories/"):
        raise PathTraversalError(f"Path must start with /memories: {raw_path}")
 
    # 前方一致済みなので relative_to() は例外を出さない
    relative = PurePosixPath(raw_path).relative_to("/memories")
    resolved = (MEMORY_ROOT / relative).resolve()
 
    # 解決後も /memories 配下にとどまっているかを最終確認する
    if not resolved.is_relative_to(MEMORY_ROOT):
        raise PathTraversalError(f"Path escapes memory root: {raw_path}")
 
    return resolved

文字列段とresolve()段を分けるのは、役割が違うからです。文字列段は単純なトラバーサルを早く落とし、resolve()段は実体のパスの位置で最終判定します。

Path.resolve()はシンボリックリンクも解決します。/memoriesの中に外部を指すリンクが作られていた場合、pathにそのリンク名を指定すると、文字列としては/memories配下に見えます。resolve()はリンクの先まで解決するので、is_relative_to()の判定で飛び先が検出されます。

実際に通した結果

保存先を一時ディレクトリのmemories/に置き換え、上のコードを手元で動かしました(Python 3.12.2)。memories/の中には、1つ上の階層にあるsecrets.envを指すシンボリックリンクlink.envを作ってあります。

pathの入力結果止めた段
/memories/../../secrets.env結果拒否止めた段文字列段
/memories/%2e%2e%2fsecrets.env結果拒否止めた段文字列段(デコード後)
/memories/%2E%2E/secrets.env結果拒否止めた段文字列段(大文字でも同じ)
/memories/..\..\secrets.env結果拒否止めた段文字列段
/memoriesX/notes.txt結果拒否止めた段区切り込みの前方一致
/memories/link.env(外を指すリンク)結果拒否止めた段is_relative_to()
/memories/notes..txt結果通過止めた段正当なファイル名
/memories/project/todo.md結果通過止めた段正当なパス

検査の落とし穴を3つ試した

素朴に書いた検証は、実測で3点が問題になりました。1つ目は、".." in decodedを使うと/memories/notes..txtまでPathTraversalErrorで落ちた点です。2つ目は、デコード結果をそのまま解決に使うと、/memories/a%20b.txtがa b.txtに化けた点です。

3つ目はNULバイトです。/memories/notes.txtに\x00を付けると、resolve()はPathTraversalErrorではなく素のValueError(embedded null byte)を投げます。呼び出し側がPathTraversalErrorだけを捕まえていると、この例外は検証エラーとして処理されずに漏れます。上のコードは冒頭でNULバイトを弾き、同じ例外型にそろえています。

%252e%252e%252fのような二重エンコードは、上のコードではmemories/の中の%252e%252e%252fsecrets.envという名前のファイルとして解決され、ルートの外には出ません。デコードを1回しかしないので、素直に文字列のまま扱われるためです。下流の層(ゲートウェイや保存先のSDK)がもう一度デコードする構成なら話が変わるので、デコードを行う層を数えておきます。

ステップ3: 6コマンドすべてに検証をかける

memory toolのコマンドはview・create・str_replace・insert・delete・renameの6種類で、そのうちrenameだけはold_pathとnew_pathという2つのパスパラメータを持ちます。検証をコマンドの分岐処理の中に個別に書くと、新しいコマンドを足したときに検証を書き忘れる余地が生まれます。ディスパッチの入口で一括して検証する形にします。

def execute_memory_command(cmd: dict) -> str:
    command = cmd["command"]
 
    if command == "rename":
        old_path = resolve_memory_path(cmd["old_path"])
        new_path = resolve_memory_path(cmd["new_path"])
        return do_rename(old_path, new_path)
 
    path = resolve_memory_path(cmd["path"])
 
    if command == "view":
        return do_view(path, cmd.get("view_range"))
    if command == "create":
        return do_create(path, cmd["file_text"])
    if command == "str_replace":
        return do_str_replace(path, cmd["old_str"], cmd.get("new_str", ""))
    if command == "insert":
        return do_insert(path, cmd["insert_line"], cmd["insert_text"])
    if command == "delete":
        return do_delete(path)
 
    raise ValueError(f"Unknown command: {command}")

resolve_memory_pathの呼び出しをdo_viewやdo_createのような個々の実装関数の中に書くと、関数を1つ追加するたびに呼び出し忘れが起こり得ます。ディスパッチの入口に集約しておけば、新しいコマンドを足しても検証だけは必ず通ります。renameのold_pathとnew_pathのように、1つのコマンドが複数のパスを持つ場合は両方を個別に検証します。片方だけだと、検証していない側がそのまま抜け道になります。

PathTraversalErrorを捕まえた呼び出し側は、tool_resultにis_error: trueを付けてClaudeへエラーメッセージを返します。公式ドキュメントのエラー例は、contentに"Error: The path /memories/notes.txt does not exist"のような文を入れ、is_errorをtrueにする形です。トラバーサルを検知した旨は、ファイルシステムの実際のエラーと区別できるようにメッセージへ含めておくと、後からログを追うときに切り分けやすくなります。is_errorの書き方の一般論はtool_resultのis_errorでエラーを正しく伝える書き方にまとめています。

一方で、実在するファイルパスの一覧や内部ディレクトリ構造をエラーメッセージに書き出すのは避けます。攻撃を試みた側に、探索の手がかりを返さないためです。

保存先がデータベースのとき

公式ドキュメントは、/memoriesを実際の保存先へ対応づけるプレフィックスとして説明し、保存先の例にユーザーごとのディレクトリとデータベースのキーを挙げています。キーで保存する構成には、resolve()が使えません。代わりにposixpath.normpath()で..を畳みます。

import posixpath
 
posixpath.normpath("/memories/../secrets.env")  # '/secrets.env'
posixpath.normpath("/memories/a/../b.txt")      # '/memories/b.txt'

実行すると、1行目は/memoriesの外の/secrets.envになり、2行目は/memories/b.txtに畳まれます。畳んだ後に/memories/で始まることを確認すれば、キーが範囲外へ出た場合を検出できます。ユーザーごとにルートを分ける構成なら、共通の/memoriesではなく、そのユーザー用のルートに対して判定します。

攻撃パターンをテストで固定する

検証ロジックを書いたら、想定する攻撃パターンをテストケースとして残しておきます。実装を変更したときに、既知の攻撃パターンが再び通ってしまう退行を防げます。

import pytest
 
 
@pytest.mark.parametrize(
    "raw_path",
    [
        "/memories/../../secrets.env",
        "/memories/../../../etc/passwd",
        "/memories/%2e%2e%2fsecrets.env",
        "/memories/..\\..\\secrets.env",
        "/memories/notes.txt\x00",  # NULバイト
        "/etc/passwd",
        "memories/notes.txt",  # 先頭スラッシュが無い
        "/memoriesX/notes.txt",  # 前方一致だけだと通ってしまう兄弟ディレクトリ
    ],
)
def test_rejects_traversal(raw_path: str) -> None:
    with pytest.raises(PathTraversalError):
        resolve_memory_path(raw_path)
 
 
@pytest.mark.parametrize(
    "raw_path",
    [
        "/memories/notes.txt",
        "/memories/notes..txt",  # .. を含むだけの正当な名前
        "/memories/project/todo.md",
        "/memories",
    ],
)
def test_accepts_valid_paths(raw_path: str) -> None:
    resolved = resolve_memory_path(raw_path)
    assert resolved.is_relative_to(MEMORY_ROOT)

外を指すシンボリックリンクのテストは、tmp_pathにos.symlinkでリンクを作る形になります。ルートを固定パスにせず、テスト用に差し替えられる形にしておくと書きやすくなります。

memory_20250818のようなツールバージョンが将来変わっても、この検証ロジック自体はmemory tool固有のAPI仕様に依存しない、純粋なパス処理です。テストケース一式も流用できます。memory toolの安全対策には、パス検証のほかに、機微情報の扱い・ファイルサイズの上限・古いメモリの削除があります。このうち機微情報をメモリファイルへ書かせない対策については、Claudeのstrict tool useとHIPAA/PHIの「スキーマの構造とやり取りされる値を区別する」という考え方が近い切り口になります。

TypeScriptでの実装方針

Node.js環境でも考え方は同じです。path.resolve()で正規化した絶対パスを求め、メモリのルートからの相対パスにpath.relative()で変換します。結果が..そのもの、または../で始まるか、絶対パスのままなら、ルートの外に出ています。..fooのような正当な名前を誤って弾かないよう、先頭が..というだけでは判定しません。

import path from "node:path";
 
const MEMORY_ROOT = path.resolve("/memories");
 
class PathTraversalError extends Error {}
 
const hasDotDot = (s: string) => s.split(/[\\/]/).includes("..");
 
function resolveMemoryPath(rawPath: string): string {
  if (rawPath.includes("\0")) {
    throw new PathTraversalError("Invalid path: NUL byte");
  }
 
  // 不正な % を含む文字列で decodeURIComponent が投げるので、失敗時は元の文字列で検査する
  let decoded: string;
  try {
    decoded = decodeURIComponent(rawPath);
  } catch {
    decoded = rawPath;
  }
 
  if (hasDotDot(rawPath) || hasDotDot(decoded)) {
    throw new PathTraversalError(`Invalid path: ${rawPath}`);
  }
  if (rawPath !== "/memories" && !rawPath.startsWith("/memories/")) {
    throw new PathTraversalError(`Path must start with /memories: ${rawPath}`);
  }
 
  const resolved = path.resolve(rawPath);
  const relative = path.relative(MEMORY_ROOT, resolved);
 
  if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) {
    throw new PathTraversalError(`Path escapes memory root: ${rawPath}`);
  }
 
  return resolved;
}

decodeURIComponent()はPythonのunquote()に相当します。ただし、不正な%を含む文字列には例外を投げます。Node.js 20.18でdecodeURIComponent("/memories/100%.txt")を素朴に呼ぶと、URIError: URI malformedになります。例外型が検証エラーとは別なので、tryで受けて元の文字列の検査に切り替えています。

同じ環境で、上のコードは/memories/notes.txtと/memories/100%.txtと/memories/notes..txtを通しました。/memoriesX/notes.txt、/memories/%2e%2e%2fsecrets.env、/memories/..\xは拒否されました。Python版と判定の骨格は同じで、言語ごとの標準ライブラリのパス関数に置き換えるだけです。

よくあるつまずき

まとめ

検証でつまずきやすい2点

  • /memoriesルート自体への delete・rename

    Claudeのツール説明では「できない」と伝えられています。それでも実装側でdeleteと、old_pathがルートのrenameを拒否します。ルートを消すと、次のセッションからメモリを復元できなくなります。

  • view のディレクトリ表示を素通りさせる

    viewはディレクトリの一覧も返します。viewだけ検証から外すと、/memoriesの外の構造を覗かれる経路が残ります。入口で一括検証すれば自然に防げます。

まとめ

ファイルシステムに保存する構成では、シンボリックリンクまで解決するresolve()後の範囲確認が最後の砦になります。データベースのキーで保存する構成ではresolve()が使えないので、normpath()後の比較がその役目を担います。保存先がどちらでも、デコードを行う層が自前のコードの下流にもあるかどうかは、先に確かめておく点です。

この記事を共有:XはてブLinkedIn