自分の文脈で多角的にニュースを解説する個人アプリ News Prism を公開しました。
News Prism は、1 つの記事 URL に対して Amazon Bedrock を 4 並列で叩き、中立要約 + 3 ペルソナの構造化 JSON を返す個人ツールです。
公開前の構築中、こんなことが頭をよぎっていました。
- 公開したら誰でもアクセスできる。ということは自分の財布でどこの誰でも Bedrock を使えてしまうのでは?
- 悪意を持った人がアクセスしてきたら何が起こる?
パブリックな URL を持った瞬間に、脅威モデルは「自分の凡ミス」から「世界中の bot と通りすがり」に変わります。
特に Amazon Bedrock のようなトークン従量課金 + 認証なし公開の組み合わせは、放置すると数日で月数万円コースになり得るものです。
そこで本記事では、公開前に取り組んだ以下の 4 つを 1 つずつ固める作業を、実装と判断と一緒に整理します。
- SSRF ガード
- サイズガード
- エラー sanitize
- Budgets
1. News Prism のアタックサーフェス
対策の話に入る前に、News Prism のどこが攻撃対象になるのかを棚卸ししておきます。
News Prism のパブリックな構成要素は以下の通りです。
- Amazon CloudFront + Amazon S3 (Web UI の配信)
- Amazon API Gateway (POST /analyze)
- AWS Lambda (実処理 — 記事 fetch + Amazon Bedrock 呼び出し + Amazon DynamoDB 保存)
このうち実害につながる挙動は 2 つに絞れます。
- Lambda が任意 URL を fetch する:記事本文取得のため。SSRF / コスト爆撃の入口
- Lambda が Bedrock を呼ぶ:トークン従量課金。コスト爆撃の本体
具体的な脅威と、本記事で扱う対策レイヤーを並べると次の通りです。
| 想定脅威 | 起こり方 | 対策レイヤー |
|---|---|---|
SSRF (IMDS 169.254.169.254) |
URL に IMDS を直接指定 | Layer 1 |
| SSRF (プライベート IP / 内部 ELB) | URL に http://10.0.0.x/ 等を指定 |
Layer 1 |
| 巨大レスポンスで Lambda OOM + 課金 | 攻撃者が用意した数 GB ファイル URL | Layer 2 |
巨大 article_body 直送 |
API に 10MB body を直接 POST | Layer 2 |
| エラー応答からの内部情報漏洩 |
169.254.169.254 を投げて応答メッセージを観察 |
Layer 3 |
| リクエスト数 × コストの総量爆発 | API キー漏洩 / クォータを緩めた瞬間 | Layer 4 |
| プロンプトインジェクション |
article_body 内に </article>\nIgnore... 等を埋め込む |
— (本文で補足) |
なお、News Prism の Lambda は VPC 外で動くので、プライベートレンジへの到達自体は限定的です。
それでも多層防御の観点から、入口で弾ける構造を入れる方針にしました。
WAF を入れる選択肢もありますが、News Prism は DynamoDB を put_item のみ (PartiQL 不使用) で叩く構成で、Web UI も Bedrock 応答を全フィールド HTML エスケープしてから innerHTML に注入しています。
そのため WAF が得意な SQLi / XSS シグネチャ系ルールが効く面がそもそもありません。
レート系 (Rate-based rules) や Bot Control は補完価値がありますが、Layer 2 (サイズ) と Layer 4 (Budgets) と機能が重なる領域です。
個人 OSS のコスト感では、追加で WAF を建てるよりもコード側で閉じる方がコスパが良いと判断し、対策は全てコード側に置く方針にしました。
SSRF やプロンプトインジェクションは、シグネチャ系・レート系どちらの WAF ルールでも直接は防げないため、別途コード側で対策しています。
プロンプトインジェクションを 4 層に入れなかった理由
脅威表に挙げたプロンプトインジェクションだけ、対策レイヤーを置いていません。
News Prism の構造上、実害が限定的に収まると判断したからです。
- 出力は Bedrock の tool_use で構造化 JSON に縛っているので、自由文を返せる経路がない
- ツール呼び出しの後段で任意のアクション (DB 書き換え / 外部 API 叩き) を起動する仕組みもない
- 想定される最悪値は、システムプロンプトに含めている
<context>(自分の個人目標) が JSON フィールドに紛れ込む程度
入力検査ベースのガードを厚く張るより、出力スキーマと後段の使い方を縛る方針で吸収する という判断です。
出力を別 API に流す、など新しい機能を追加する時は、この前提は見直す必要がありそうです。
2. Layer 1: SSRF ガード
最初のガードは、Lambda が任意 URL を fetch する経路 に対するものです。
記事本文を取りに行く処理 (urllib.request.urlopen) にユーザー由来の URL をそのまま渡すと、典型的な SSRF パターンになります。
スキーマチェック (startswith("http")) だけでは不十分で、DNS による名前解決後の IP が private / loopback / link-local / multicast / reserved / unspecified のいずれにも該当しないこと も確認します。
News Prism の http_safety.py (簡略版) は次のような形です。
import ipaddress, socket
def is_ip_safe(ip):
return not (
ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_multicast or ip.is_reserved or ip.is_unspecified
)
def check_host_safe(host):
# IP リテラル直書きも検査
try:
ip = ipaddress.ip_address(host)
if not is_ip_safe(ip):
raise UnsafeURLError(f"refused {host}")
return
except ValueError:
pass
# DNS 解決後の全 IP を検査
for info in socket.getaddrinfo(host, None):
ip = ipaddress.ip_address(info[4][0])
if not is_ip_safe(ip):
raise UnsafeURLError(f"{host} resolves to {ip}")
これで http://169.254.169.254/ や http://10.0.0.x/ のような URL は、Bedrock に届く前に弾けます。
リダイレクト追従の落とし穴
初回 URL の IP は弾けても、サーバが 3xx リダイレクトで別 URL に飛ばしてきた場合、その先でプライベート IP に名前解決されると意味がありません。
SSRF の典型的な bypass パターンの一つです。
そのため、各リダイレクト hop でホスト名を再検査する必要があります。
ここは SSRF の典型 bypass として知られている対策で、OSS の SSRF 対策ライブラリで定番のパターンに倣って入れた部分です。
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, max_redirects: int) -> None:
super().__init__()
self.max_redirects = max_redirects
self._count = 0
def redirect_request(self, req, fp, code, msg, headers, newurl):
if self._count >= self.max_redirects:
raise UnsafeURLError(f"redirect limit exceeded (>{self.max_redirects})")
self._count += 1
parsed = urlparse(newurl)
check_host_safe(parsed.hostname)
return super().redirect_request(req, fp, code, msg, headers, newurl)
DEFAULT_MAX_REDIRECTS = 5 で hop 数の上限を設けて、リダイレクトループも 5 回までで打ち切るようにしています。
DNS rebinding は意図的にスコープ外
ここまででホスト検査は通っても、check_host_safe で safe と判定した直後に urlopen が再度 DNS で名前解決します。
そのため攻撃者が低 TTL の悪性 DNS を仕込めば、理論上 bypass できます (典型的な TOCTOU)。
完全防御するには名前解決済み IP を pin して直接接続する必要があり、urllib で組むと実装コストがハードルになります。
News Prism の Lambda は VPC 外で動き、到達できる内部リソースが限定的です。
名前解決の時点で弾けることを Layer 1 のスコープとして、DNS rebinding 対策は外しました。
VPC 内 Lambda や、IMDS / 内部 ELB に到達されると致命的になる構成では、ここを足す必要があります。
3. Layer 2: サイズガード (入力 + 応答)
2 番目のガードは、サイズです。
入力サイズをコード側で強制する上限は、トークン従量課金を縛る最初のガード になります。
News Prism のサイズ縛りは入力側と応答側の 2 軸に分かれます。
| 軸 | どこ | 上限 |
|---|---|---|
| 入力サイズ | API に POST される本体 |
url ≤ 2,048 charsarticle_body ≤ 100,000 chars |
| 応答サイズ | Lambda が外部 URL から fetch する本体 |
max_bytes + 1 で読み切り |
入力サイズ
Amazon API Gateway 経由の Lambda はペイロード上限が 10MB あるため、サイズ縛りを入れないと 1 リクエストあたり 10MB の article_body を POST できてしまいます。
article_body 100,000 chars の縛りは、Sonnet で 25-50K トークン、1 リクエストあたり数十円に収まる範囲 という根拠で設定しました。
_MAX_URL_CHARS = 2048
_MAX_ARTICLE_BODY_CHARS = 100_000
if len(url) > _MAX_URL_CHARS:
return _response(400, {"error": f"field 'url' too long (>{_MAX_URL_CHARS} chars)"})
if len(article_body) > _MAX_ARTICLE_BODY_CHARS:
return _response(400, {"error": f"field 'article_body' too long (>{_MAX_ARTICLE_BODY_CHARS} chars)"})
サイズ上限を縛らなかった場合、トークン量の違いは次のように現れます。
| 状態 | 入力トークン | 備考 |
|---|---|---|
| 100K chars 縛りあり | 25-50K | 現実の上限 |
| 上限なし (10MB ペイロード) | 800K-1.6M | 英語ベースで 33 倍、日本語 UTF-8 (3 bytes/char) で 100 倍 |
具体的な金額は Bedrock のトークン従量課金で変動するので、実機ベンチ値は出していません。
ただ「トークン量で見て 33 倍以上」という事実だけで、「上限なし」が危ういことは伝わるのではないでしょうか。
上限値の選び方が判断ポイントで、記事ベースの AI ツールなら 100K chars 程度、チャットボットなら別の数字になりそうです。
出力側にも max_tokens で蓋をしておくと、双方向でトークン従量課金を縛れます。
応答サイズ
URL fetch の受信側にも、同じ思想でサイズ縛りをかけています。
攻撃者が用意した数 GB のファイルを URL として投げ込まれると、Lambda メモリが枯渇したり、受信データが Bedrock のコンテキストに流れて課金爆撃になる経路があるためです。
with opener.open(req, timeout=timeout) as resp:
declared = resp.headers.get("Content-Length")
if declared is not None and int(declared) > max_bytes:
raise UnsafeURLError(f"declared Content-Length {declared} exceeds {max_bytes}")
data = resp.read(max_bytes + 1)
if len(data) > max_bytes:
raise UnsafeURLError(f"response exceeded {max_bytes} bytes")
事前に Content-Length を確認しつつ、ヘッダが嘘や欠落の場合に備えて max_bytes + 1 で読み切る 2 段構えです。
入力サイズと応答サイズ、どちらも「サイズで上限を縛る」という同じ思想で揃えました。
4. Layer 3: エラー応答のサニタイズ
3 番目のガードは、エラー応答です。
内部詳細をそのまま出すと、攻撃者の偵察を助けることになります。
例えば次のようなエラーは、攻撃者にとって有用な情報源になります。
-
article fetch failed: refused 169.254.169.254(IMDS が弾かれた事実が漏れる) -
internal ELB resolved to 10.0.0.5(内部ホスト名や IP が漏れる)
これらの詳細情報はサーバ側のログにだけ残して、クライアントには汎用的なエラーを返すようにしています。
except ArticleFetchError as e:
logger.warning("article fetch failed: %s", e) # サーバ側のみ
return _response(
422,
{
"error": "article fetch failed",
"hint": "POST { url, article_body } で本文を直接送る fallback が使えます",
},
)
しかしこうすることで、自分のデバッグ体験 が地味に不便になります。
これまでブラウザの開発者ツールでエラー本文を見ればよかったところを、Amazon CloudWatch Logs を見にいく運用に切り替えることになります。
これに関しては「他人に見えるエラーは汎用的」「自分は CloudWatch を確認する」という分離を受け入れることになります。
5. Layer 4: AWS Budgets — 4 層目はカネ
最後のガードは、お金です。
Layer 1-3 で「1 リクエストあたりの最悪値」と「URL fetch の暴走」は縛れました。
それでも、リクエスト数 × コストの総量が爆発するシナリオは残ります。
API キーが漏洩したケースや、クォータを緩めた瞬間に攻撃を受けたケースです。
これに対しては AWS Budgets を設定して、コスト起点で異常に気が付ける状態にします。
閾値超過時に SNS / Email で通知を受けて、手動で Lambda を停止する / API キーを失効する流れとなります。
より厳密に固めたい場合は、AWS Budgets Actions で IAM の拒否ポリシーを自動アタッチしたり、CloudWatch メトリクス + EventBridge + Lambda で Bedrock invocation 数を起点に強制停止するなど、自動停止の仕組みを検討すると良さそうです。
本記事では「気付ける状態を作るところまで」を 4 層目として扱い、自動停止の実装はスコープ外としました。
まとめ
最初は「自分しか使わないツール」のつもりで作っていましたが、パブリックな URL を持った瞬間に守る対象は変わります。
「自分の財布でどこの誰でも Bedrock を使えるのでは?」という小さな問いから出発して、SSRF / サイズ / エラー応答 / Budgets の 4 つを順に対策してきました。
これらは完璧な防御とは言えず、認証なしでパブリック公開するために必要最低限の仕組みを置いた、という位置付けになるかと思います。
意思の力ではなく仕組みで自分の財布を守る、その輪郭が見えただけでも、設計・実装した価値はあったように感じます。
お財布を痛めないためにも、個人ツールを公開する前には、ぜひ一度ガードを棚卸ししておきたいですね。
今日も小さな学びを。
News Prism 関連記事