allowed_domainsのホモグラフ攻撃対策 — キリル文字でamazon.comを偽装される穴
Claude APIのweb_search/web_fetchが使うallowed_domainsに、キリル文字でamazon.comを偽装した1文字が混ざる手口と、許可リストをASCIIで監査するスクリプトを解説します。
allowed_domainsに紛れる1文字は目では見つからない
allowed_domains と blocked_domains に書いたドメイン名に、ASCIIではない文字が1つ混ざっているだけで、そのエントリは別のドメインを指します。キリル文字の а のように、ラテン文字の a と同じ形に見える文字が典型です。Server toolsのDomain filteringの節には、この手口を名指しした警告があります。対策は2つで、リストにはASCIIだけのドメイン名を書くことと、既存のエントリに非ASCII文字がないか調べることです。Messages APIはUnicodeのエントリを受け付けるので(推奨はされていません)、混入してもリクエストは弾かれず、気づく機会がありません。
どちらもAPIの側が代わりにやってくれる処理ではなく、リストを管理する側の仕事です。この記事では、手口の中身、見落としやすい web_fetch のパス指定、リストを機械的に調べるスクリプトの順に扱います。web_search の基本はWeb検索の使い方にあります。
見た目が同じドメインは、文字としては別物になる
警告文の例は аmazon.com です。先頭の а がキリル文字で、本物の amazon.com とは別のドメインです。Python 3.12で調べると、違いは次のように出ます。
>>> "аmazon.com" == "amazon.com"
False
>>> "аmazon.com".isascii()
False
>>> "аmazon.com".encode("idna")
b'xn--mazon-3ve.com'同じに見える2つの文字列
amazon.com
先頭はU+0061(LATIN SMALL LETTER A)です。アドレスバーでもエディターでも、次の文字と同じ見た目になります。
аmazon.com
先頭はU+0430(CYRILLIC SMALL LETTER A)です。ラテン文字の a とはコードポイントもUnicode名も別の文字で、字形だけが重なります。
コピー&ペーストで許可リストに貼った1文字が混ざると、意図した amazon.com ではなく、見た目の似た別ドメインを書いたことになります。逆向きの事故もあります。blocked_domains に書いたつもりのドメインが別の文字列になっていれば、遮断が効きません。
web_search では検索結果、web_fetch では取得した本文が、そのまま会話のコンテキストに入ります。偽装ドメインの内容がClaudeの以降の回答の材料になる可能性がある点が、単なるアクセス制御の設定ミスより重い理由です。
web_searchとweb_fetchでは、パス付きエントリの意味が違う
ホモグラフとは別に、同じドメインフィルタリングで取り違えやすい仕様があります。パスを付けたエントリの扱いです。
パス付きエントリ(example.com/blog)の扱い
web_search
example.com/blog は example.com/blog/post-1 のように、パスの後ろに続くものすべてに一致します。パス部分には * も使えます。
web_fetch
照合はドメインだけで行われ、パスを含むエントリは取得URLに一致しません。allowed_domains に書いたパス付きエントリは、何も許可しないことになります。
web_fetch で取得先を絞りたいときは、パスを含めずにドメイン名だけで書きます。取得を断られたときは、エラーコード url_not_allowed が返ります。blocked_domains にパス付きで書いた場合も、エントリが一致しないので遮断としては働きません。
そのほかの書式ルールは次のとおりです。
- スキーム(
https://)は付けない - サブドメインは自動で含まれる。
example.comを書けばdocs.example.comも対象になる - 特定のサブドメインだけを書くと、そのサブドメインに限られ、
example.comやapi.example.comは対象外になる - ワイルドカードはパスの中だけで有効で、
*.example.comとex*.comは使えない allowed_domainsとblocked_domainsは同じリクエストでは併用できず、併用すると400になる
不正な形式は、リクエストの時点で400 invalid_request_error になります。400全般の見方はClaude APIのエラーハンドリング設計にまとめています。
許可リストを機械的に調べる
ホモグラフは目視のレビューでは見つかりません。フォントを変えても同じ形に見えることが前提の手口だからです。調べるのは文字の見た目ではなく、コードポイントです。
許可リストの監査手順
- 1
調べる対象を集める
allowed_domainsとblocked_domainsに渡す配列を、JSONの配列として取り出します。コードに書いたリストだけでなく、設定ファイルや管理画面から読み込む値も対象です。 - 2
非ASCII文字を洗い出す
下のスクリプトに配列を渡し、非ASCII文字を含むエントリを、何文字目のどの文字かまで出力させます。
- 3
見つかったら入力元に戻る
エントリを直すだけでなく、混入した経路を確かめます。貼り付け元のドキュメント、外部から受け取ったドメインの一覧、フォームからの入力が候補です。
- 4
CIの手前に置く
終了コードが1なら失敗させる設定にして、リストを変更するコミットの検査に組み込みます。
次のスクリプトは、標準入力のJSON配列を読み、非ASCII文字があれば位置とUnicode名を出して終了コード1で終わります。
import json, sys, unicodedata
bad = 0
for entry in json.load(sys.stdin):
for i, ch in enumerate(entry):
if not ch.isascii():
bad += 1
print(f"{entry!r}: {i}文字目 U+{ord(ch):04X} {unicodedata.name(ch)}")
sys.exit(1 if bad else 0)キリル文字に加えて、全角の e を混ぜた例で動かします。
echo '["example.com", "docs.python.org", "аmazon.com", "example.org"]' | python3 audit_domains.py出力は次のとおりで、終了コードは1です。
'аmazon.com': 0文字目 U+0430 CYRILLIC SMALL LETTER A
'example.org': 0文字目 U+FF45 FULLWIDTH LATIN SMALL LETTER EASCIIだけの2件(example.com と docs.python.org)を渡すと、何も出力せず終了コード0で終わります。isascii() だけを使った1行版でも検出はできますが、どこが違うのかが出ないため、混入元を探す段階で手間が増えます。
日本語ドメインのような正規の国際化ドメイン名を許可したい場合、このスクリプトは失敗を返します。Managed Agentsのドキュメントは、国際化ドメイン名を xn-- で始まるPunycode形で書くよう定めていて、非ASCII文字を含むドメインは拒否されます。Messages APIでも、xn-- 形で書けばASCIIだけになるので、このスクリプトを通ります。Messages APIで xn-- 形のエントリが照合に効くかどうかは、自分のリクエストで試してから運用に入れます。
監査を入れるタイミング
監査を挟む場所は3つあります。
- リストへのエントリ追加時。CIの検査に入れれば、人手を介さずに続けられます
- 既存リストの棚卸し。コピー&ペースト由来の混入は後から見つかることもあるので、定期的に全件を流します
- 外部から受け取ったドメインを取り込む直前。パートナーや利用者の申請は、自分で入力した値より混入のリスクが高くなります
web_search には、ドメインフィルターのリストが長いと request_too_large になりうるという記載もあります。リストを増やし続ける運用では、棚卸しで件数を減らすことも監査の一部になります。
blocked_domainsでは、ホモグラフを防げない
拒否リストは、遮断したいドメインを先に知っていなければ書けません。攻撃者が使う偽装ドメインは事前に予測できず、1件ずつ足していく運用では新しいものに追いつけません。
許可リストは逆で、通すものを先に固定し、残りをすべて弾く構成です。リストに載っているエントリがASCIIだけであることを保証できれば、未知のホモグラフドメインは、許可リストに無い時点で通りません。セキュリティ目的でドメインを絞るなら、allowed_domains が向いている理由です。
組織の設定とManaged Agentsでは、効き方が変わる
Claude Consoleの組織設定にもドメイン制限があります。Messages APIのリクエストでは、組織の設定とリクエストの allowed_domains の両方が効きます。リクエスト側の許可リストは、組織側の許可リストの部分集合でなければならず、外れるエントリがあると検証エラーになります。組織がブロックしているドメインをリクエスト側の許可リストに入れたときは、該当エントリを名指しした400が返ります。
したがって、ホモグラフの監査はリクエスト側のリストだけでは足りず、Consoleの許可リストも対象にします。
Claude Managed Agentsのツールセットでも、web_search と web_fetch のエントリに同じフィールドを設定できますが、ルールが違います。
| 項目 | Messages API | Managed Agents |
|---|---|---|
| 件数 | Messages API上限の数値は記載なし(長すぎると request_too_large) | Managed Agents各リスト最大64件 |
| 非ASCII文字 | Messages API受け付ける(非推奨) | Managed Agents形式違反として400 |
web_fetch のパス | Messages API付けても一致しない | Managed Agents含められない |
max_uses / citations / cache_control | Messages API使える | Managed Agents使えない |
| Consoleの組織設定 | Messages APIリクエスト側と併せて効く | Managed Agents適用されない |
Managed Agentsでは組織設定が効かず、ツールセット側のリストだけが境界になります。Managed Agentsのドメインは、ASCIIの英数字・ハイフン・アンダースコア・ドットに限られます。形式違反は、エージェントの作成・更新時に400で拒否されます。自前の監査が主に要るのは、Messages APIのリストです。web_search と web_fetch はそれぞれ別のリストを持つので、別々の制限をかけている場合は両方を調べます。
許可されないURLに対する挙動も違います。web_fetch はエラー結果(url_not_allowed)を返し、web_search は許可されない結果を除きます。ツールを大量に扱う設計全般はAdvanced Tool Useに書いています。
ドメイン絞り込みとは別に残る防御
web_fetch には、ドメインの絞り込みとは別に、会話に一度も現れていないURLは取得しないという制限があります。Claudeの出力にだけ現れたURLは取得できず、取得できるのはユーザーのメッセージ、クライアント側ツールの結果、これまでの検索や取得の結果に出てきたURLだけです。エラーコードは url_not_in_prior_context で、原因と対処はweb_fetchのurl_not_in_prior_contextエラーの原因と回避策にまとめました。
この制限が防ぐのは、Claude自身が組み立てたURLへのアクセスです。許可リストに偽装ドメインが入り、そのドメインのURLがユーザーの入力や検索結果に現れれば、取得は進みます。ホモグラフ対策の代わりにはなりません。
ダイナミックフィルタリングでも、リストの検査は手前で行う
_20260209 以降のweb_searchとweb_fetchは、検索結果の絞り込みに内部でコード実行を使います。code_execution ツールを自分で足す必要はなく、APIが自動で用意し、2つのツールで1つの実行コンテナーを共有します。
_20260209 以降のツールは、既定の呼び出し元がコード実行だけです。以前のバージョンは ["direct"] が既定です。_20260209 以降をプログラムからのツール呼び出しに対応しないモデルで使うときは、allowed_callers: ["direct"] の指定が必要で、省くと検証エラーになります。
どのバージョンでも、allowed_domains に渡す文字列はリクエストに書いた値です。非ASCIIの検査は、リクエストを組み立てるコードかCIで終わらせておく構成が扱いやすくなります。
まとめ
許可リストと拒否リストの検査は、文字の見た目ではなくコードポイントで行います。web_fetch のエントリはドメイン名だけで書き、Consoleの設定も同じ検査にかけると、意図しないドメインを通す穴を手前で塞げます。