ダウンロード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 に限らず、「ページを返す側」と「ファイルを返す側」の両方を自分で触れる環境なら応用できます。

作る順序

  1. 守る範囲を決める。ダウンロードページのURLが印刷済みなら、ページは開けたままにして、ファイル本体だけ守る
  2. 秘密鍵を作り、サーバー側の設定にだけ置く(ページのHTMLやリポジトリに書かない)
  3. ページを返すとき、ボタンのリンクに「商品」と「発行時刻」入りの署名付きトークンを足す
  4. ファイルを返すとき、署名・15分以内かどうか・商品の一致を確かめ、外れたら 403 にする
  5. 403 の画面に、ページを開き直して押し直す案内とリンクを出す
  6. ページとファイルにキャッシュ禁止を付ける
  7. robots.txt と noindex で検索に出さない

公開前に外から測る項目

  • ページが 200 で開き、Cache-Control が no-store になっているか
  • トークン無しのZIPが 403 になるか
  • ページ発行のトークンでZIPが落ちるか
  • 別の商品のトークンが 403 になるか
  • 署名の一部を書き換えると 403 になるか
  • 時間が足りれば、期限を過ぎたトークンが 403 になるか

運用で見落としやすい点

  • 秘密鍵が未設定のとき、素通しにならず止まるか
  • 版を上げたあと、古いZIPが同じページに残っていないか
  • 期限切れの画面で、購入者が次に何をすればよいか分かるか

どこまでを機械に任せ、どこから人が判断するかで言えば、直リンクの寿命を縮めるところまでは機械に任せられます。購入者かどうかの確認まで広げるかは、手間と購入者の負担を見て、販売する側が決める部分です。

15分で切れる署名付きリンクは、導入前の「URLをコピーすれば誰でも取れる」状態をなくします。ただし、ページのURLが渡れば、その先も渡ります。この線引きを先に決めておくと、対策の範囲と保留する範囲を説明できます。