ダウンロードURLの拡散で何が困るのか
relmea の購入者は、販売記事や同梱のREADMEに書かれた「ダウンロードページ」を開き、そこのボタンからZIPを落とします。ダウンロードページは6つあり、内訳は教材3巻とツール3種です。
導入前は、ZIP本体のURL(直リンク)をコピーすれば、誰でも取れる状態でした。他のサイトから直リンクを貼られても防げませんでした。
困る点は2つあります。
- 購入していない人にも、URLを知っているだけで渡る
- 他サイトから直リンクされると、配布の入口を自分で管理できない
一方で、変えられないものもありました。ダウンロードページのURLは、販売記事の本文とREADMEに印刷済みです。変えると、すでに購入した人が落とせなくなります。
対策の方針は「ページは開けたまま、ZIPだけ合言葉を必須にする」
最初に決めたのは、守る範囲を絞ることです。
ダウンロードページは今まで通り、誰でも開けます。ZIP本体だけ、合言葉(署名)がないと渡さないことにしました。
この方針にした理由は、既存の購入者の導線を一切壊さないことを最優先にしたからです。ページのURLもボタンの位置も変わらないので、購入者から見える変化はありません。
合言葉は購入者に入力してもらうものではありません。サーバーがページを返すときに、ボタンのリンクへ自動で付け足します。
15分で失効する署名付きリンクの仕組み
流れを順に書きます。
1. ページを返すとき、ボタンのリンクに短命のトークンを足す
サーバーはダウンロードページを返すとき、ボタンのリンクの末尾に「?t=…」というトークンを付けます。Cloudflare の HTMLRewriter を使い、返す瞬間にだけ書き換えます。ページのHTMLファイル自体は書き換えていません。
2. トークンには商品と発行時刻を入れ、秘密鍵で署名する
トークンの中身は2つです。
- どの商品のページか
- 発行した時刻
これに、サーバーだけが持つ秘密鍵で HMAC-SHA256 の署名を付けます。鍵を知らない人は、正しい署名を作れません。
3. ZIPを求められたら3つを確かめる
ZIPを求められたとき、サーバーは次の3つを確かめます。
- 署名が正しいか
- 発行から15分以内か
- そのページの商品と一致するか
1つでも外れれば 403 を返します。このとき、有効期限切れの案内と、ダウンロードページへ戻るリンクを出します。
4. 署名の照合は、所要時間で差が出ない比較にする
署名を普通に比べると、先頭から何文字合っているかで応答時間がわずかに変わります。その差を手がかりに署名を当てられる余地を消すため、所要時間が一定になる比較方法を使っています。
5. ページとZIPの両方に、キャッシュ禁止を付ける
ページにもZIPにも Cache-Control: private, no-store を付けます。トークン付きのURLやHTMLが共有キャッシュに残り、別の人に使い回されるのを防ぐためです。
6. 秘密鍵が未設定なら、素通しにせず止める
秘密鍵が設定されていないとき、保護を外して配ることもできます。relmea ではそうせず、ZIPは 503 で止め、問い合わせ先メールを出して「個別に送ります」と案内します。
黙って保護が外れるより、落ちていることが見えるほうを選びました。
7. 検索に出さない
robots.txt で /dl/ をクロール禁止にし、ページ側にも noindex, nofollow を付けています。ダウンロードページが検索結果から見つかる経路を閉じるためです。
本番で測った結果と、まだ測っていないこと
2026-10-24に、Python から外部(購入者と同じ立場)でサイトを叩いて測りました。6項目の結果は次の通りです。
- ダウンロードページ: 200 が返り、Cache-Control は private, no-store だった
- トークン無しでZIPを直接取りに行く: 403
- ページが発行したトークン付きリンク: 200 で、ZIP(201,896バイト)を取得できた
- 教材1巻目のトークンで、別の巻のZIPを取りに行く: 403
- トークンの末尾3文字を書き換える: 403
- robots.txt に Disallow: /dl/ がある: はい
一方、測っていないことが1つあります。
15分を過ぎたトークンが 403 になるかは、今回は待って測っていません。「15分で失効する」のは、コード上の仕様として確認した内容です。実測の6項目には含まれません。
15分という長さの根拠は、ページを開いてボタンを押すまでには十分で、URLを貼って回すには短い長さとして決めた、というところまでです。それ以上のデータはありません。
拡散対策として守れない3つの限界
この仕組みで、拡散が止まるわけではありません。コードのコメントにも残してある限界を3つ書きます。
1. ページのURLを知っている人は、ZIPを取れる
ダウンロードページは誰でも開けるので、そのURLを知っている人は、トークン付きのZIPを取れます。URLは推測できない長さのランダム文字列ですが、購入者が意図して共有すれば漏れます。
2. 守れているのは「購入者の確認」ではなく「直リンクの寿命」
つまりこの仕組みは、購入者かどうかを確かめる認証ではありません。コピーされた直リンクの寿命を15分に縮める仕組みです。
本当に購入者だけに渡すには、販売プラットフォームの購入者リストとメールアドレスを突き合わせる段階が要ります。ただ、手間が増え、購入者の負担も増えます。これは別の判断として保留しています。
3. トークンは「商品ページ」に紐づき、「ファイル名」には紐づかない
同じページの中に古い版のZIPを置いたままにすると、同じトークンで古い版も取れます。実際、1巻目のページの中には、現行版と1つ前の版のZIPが2つ残っています。
版を上げたら古いファイルを消すか、ファイル名まで署名に入れる必要があります。
自分で入れるときの手順とチェックリスト
最後に、自社サイトに同じ考え方を入れるときの順序をまとめます。Cloudflare Pages に限らず、「ページを返す側」と「ファイルを返す側」の両方を自分で触れる環境なら応用できます。
作る順序
- 守る範囲を決める。ダウンロードページのURLが印刷済みなら、ページは開けたままにして、ファイル本体だけ守る
- 秘密鍵を作り、サーバー側の設定にだけ置く(ページのHTMLやリポジトリに書かない)
- ページを返すとき、ボタンのリンクに「商品」と「発行時刻」入りの署名付きトークンを足す
- ファイルを返すとき、署名・15分以内かどうか・商品の一致を確かめ、外れたら 403 にする
- 403 の画面に、ページを開き直して押し直す案内とリンクを出す
- ページとファイルにキャッシュ禁止を付ける
- robots.txt と noindex で検索に出さない
公開前に外から測る項目
- ページが 200 で開き、Cache-Control が no-store になっているか
- トークン無しのZIPが 403 になるか
- ページ発行のトークンでZIPが落ちるか
- 別の商品のトークンが 403 になるか
- 署名の一部を書き換えると 403 になるか
- 時間が足りれば、期限を過ぎたトークンが 403 になるか
運用で見落としやすい点
- 秘密鍵が未設定のとき、素通しにならず止まるか
- 版を上げたあと、古いZIPが同じページに残っていないか
- 期限切れの画面で、購入者が次に何をすればよいか分かるか
どこまでを機械に任せ、どこから人が判断するかで言えば、直リンクの寿命を縮めるところまでは機械に任せられます。購入者かどうかの確認まで広げるかは、手間と購入者の負担を見て、販売する側が決める部分です。
15分で切れる署名付きリンクは、導入前の「URLをコピーすれば誰でも取れる」状態をなくします。ただし、ページのURLが渡れば、その先も渡ります。この線引きを先に決めておくと、対策の範囲と保留する範囲を説明できます。
