【Cloudflare】外から Notion API の枠を使い切られないように、画像URLに署名を付けて WAF でレート制限した

この記事でわかること
- 匿名のリクエスト1回ごとに Notion API を呼んでいた2つの経路
- 画像URLに HMAC の署名を付けて、本文に載せたブロック以外は Notion に問い合わせずに 404 にする実装
- 知らない slug は Notion に行かず、Notion のエラーを「見つかりません」にしないようにした理由
- Cloudflare WAF のレート制限を Terraform で入れたときの Free プランの制約
- 150回の連打で48回が 429 になり、記事ページは 200 のままだった検証結果
Notion などレート制限のある API を、リクエストのたびに呼ぶ構成のサイトを運用している方向けです。Cloudflare は Free プランの前提で書いています。
概要
この記事では、このブログで「外からのリクエストで Notion API の枠を使い切られうる」状態になっていたのを塞いだときのことを書いていきます。
セキュリティの点検をしていて見つけたもので、実際に攻撃されたわけではありません。ただ、仕組みとしては誰でも匿名で、でたらめな URL を投げ続けるだけで記事が出なくなる可能性があったので、コードと Cloudflare の設定の両方で直しました。
対象者
- Notion など、レート制限の厳しい API をリクエストのたびに呼んでいるサイトを運用している方
- Cloudflare の WAF のレート制限を Terraform で入れてみたい方
動作環境
| 項目 | 内容 |
|---|---|
| フレームワーク | Next.js 15(App Router、Edge Runtime) |
| 配信 | Cloudflare Pages(Free プラン) |
| CMS | Notion(REST API を直接 fetch) |
| IaC | Terraform(Cloudflare provider v5) |
何が問題だったのか
Notion の API は、1つのインテグレーションあたり平均3リクエスト/秒に制限されています。このブログではこの枠を、記事の描画、デプロイ時のウォーム、CI の書き戻しスクリプトで共有しています。
そのうえで、匿名で叩けて、1回ごとに Notion を呼ぶ経路が下記の2つありました。
/api/notion-image/{blockId}:Notion の画像URLは1時間で失効するので、本文にはこの URL を埋めて、リクエストのたびに Notion のGET /blocks/{id}で取り直しています。ここが UUID の形をしていれば何でも受け付けていて、存在しない ID の 404 もキャッシュしていませんでした/blogs/{slug}:知らない slug ごとに Notion の DB クエリが走っていました。「無い」のキャッシュは slug 単位なので、毎回違う slug なら毎回 Notion に届きます
さらに、記事ページは Notion の取得に失敗するとすべて「記事が無い」として notFound() を返していました。存在しない slug のページは 200 で返って CDN にキャッシュされるので、Notion が 429 を返している最中にキャッシュが切れた実在の記事は、「ページが見つかりません」のまま最大7日 CDN に残る可能性があります。こちらはコードを読んだうえでの推測で、実際に再現はしていません。
画像URLに署名を付ける
1つ目の経路は、本文を組み立てるときにブロック ID の HMAC を ?s= として埋めて、API 側で署名が合わないものは Notion に問い合わせずに 404 にするようにしました。本文に載せたブロックの URL しか通らなくなります。
鍵は新しいシークレットを増やしたくなかったので、NOTION_API_KEY から導出しています。Edge Runtime なので Web Crypto の crypto.subtle を使っています。
key: crypto.subtle.importKey(
'raw',
encoder.encode(`kt-tech.blog/notion-image:${secret}`),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
),署名は16進で32文字(128bit)に切っています。照合のほうは、比較にかかる時間から1文字ずつ当てられないように、途中で抜けずに最後まで比べるようにしました。
let diff = 0;
for (let i = 0; i < SIGNATURE_HEX_LENGTH; i++) diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
return diff === 0;トークンを入れ替えると署名も変わりますが、Pages のシークレットの変更は再デプロイで反映されて、デプロイでは CDN を全パージしているので、古い署名の本文が残ることはありません。
知らない slug は Notion に行かない
2つ目の経路は、記事一覧(キャッシュ済み)に無い slug を、Notion に問い合わせずにその場で「無い」とするようにしました。一覧はもともとキャッシュに載っているので、Notion への往復は増えません。
あわせて、記事ページで notFound() を返すのは本当に「記事が無い」ときだけにして、429 などの Notion のエラーは error.tsx に回すようにしました。これで、Notion が絞っている間に「見つかりません」が CDN に載ることは無くなります。
WAF のレート制限を Terraform で入れる
コードで直せるのはここまでで、残りは Cloudflare の WAF のレート制限で /api/ への連打を止めることにしました。D1 と Turnstile を Terraform で管理していたので、そこに足しています。
resource "cloudflare_ruleset" "rate_limit" {
zone_id = data.cloudflare_zone.site.zone_id
name = "kt-tech.blog rate limit"
kind = "zone"
phase = "http_ratelimit"
rules = [{
ref = "api_burst"
expression = "(starts_with(http.request.uri.path, \"/api/\"))"
action = "block"
ratelimit = {
characteristics = ["cf.colo.id", "ip.src"]
period = 10
requests_per_period = var.api_rate_limit_per_10s # 60
mitigation_timeout = 10
}
}]
}Free プランだと、ルールは1本、集計期間とブロック時間は10秒、キーは IP と colo の組み合わせだけ、という制約があります。正直もう少し細かく決めたかったのですが、/api/ の連打を止めるだけなら十分かなと思いました。
しきい値は10秒に60回にしています。本文画像は1枚ごとに /api/notion-image を通るので、画像の多い記事を開いても引っかからない値にしました。記事ページ(/blogs/)は CDN から返りますし、知らない slug も Notion を呼ばなくなったので対象から外しています。
Terraform 用の API トークンには、このゾーンだけを対象にした Zone Read と Zone WAF Edit を足しました。
本番で確かめる
デプロイしたあと、本番で下記を確認しました。
| 確認したこと | 結果 |
|---|---|
| 署名なしの画像URL | 即 404(Notion に問い合わせない) |
| 存在しない slug | 「見つかりません」のページ |
| 普通の閲覧(記事ページ・件数 API) | 200 |
/api/ に150回連続で送る |
48回が 429(102回目以降) |
| ブロック中の記事ページ | 200 のまま |
| ブロックの解除 | 約10秒 |
しきい値は60回なのに102回目から 429 になっているのは、Cloudflare のレート制限の集計が近似だからだと思います(多分)。いずれにしても、連打は止まって、普通に読む人には影響が出ていないことは確認できました。
感想
Notion を CMS にしていると、Notion API の枠は「自分のサイトのための資源」だと思いがちなのですが、匿名で叩ける経路があると外からも使えてしまうんだなと思いました。キャッシュで往復を減らすことばかり考えていて、「でたらめなリクエストでも Notion に行く」経路はめちゃくちゃ見落としていました...
個人的には、レート制限より先に「そもそも Notion に行かない」ようにするほうが効くと思っています。署名と slug の判定でほとんど塞がって、WAF は最後の保険という感じです。
おわりに
Notion のような制限のある API をリクエストのたびに呼んでいる方は、一度「匿名で、1回ごとに外部 API を呼ぶ URL」がどれだけあるか洗い出してみると良いかもしれません。


