某所で話題になっていた記事を読んで、自分にも興味ある内容だったので参戦してみました:

絶対に画像をダウンロード&スクレイピングさせないWebページを本気で作ってみた


内容は上記サイトのタイトル通りです。自分はマンホールマップお絵描き日記といった画像投稿機能を持つサービスを開発・運営しています。そんな中でサービスに参加していただいたユーザーが投稿した作品を「勝手にダウンロードして別のところで使われる」ことはサービス運営の観点で好ましくない自体ではあります。一方で「ウェブで閲覧できる画像のダウンロードを技術的に禁止する」のは結構難しいテーマで、これまで真剣に取り組んでいませんでした(ブロックチェーンを使ったり、作品の NFT 化といった対処は既に実装しているのですが、これらはいずれも「オリジナル作品のダウンロードそのものを禁止する」という技術ではありません)。で、上記サイトを見て、改めて自分でも本気でこのテーマに取り組んでみようと思ったわけです。


【画像のダウンロード禁止がなぜ難しいか?】
ここからは少しウェブの知識を必要とする内容になります。まずは「そもそも何故ウェブ画像のダウンロード禁止が難しいか?」を説明します。

普通のウェブページで普通に画像を表示しようとすると、<img> という HTML タグを使って表示します。例えばこんな感じです:
<img src="https://avatars.githubusercontent.com/u/3183150?v=4" />


<img> というタグが画像を表示する機能を持ち、その "src" という属性で画像の URL(場所)を指定します。この例であれば "https://avatars.githubusercontent.com/u/3183150?v=4" が画像の URL になっています。この URL をウェブブラウザで直接指定すると画像だけが表示されます(他にも <img> の属性があり、指定内容によって見え方を変えることができますが、ここでは省略します)。このような「普通の」画像は画像上を右クリックしてから「画像を保存」することで簡単にダウンロードできてしまいます:

2023060403


このように「ウェブで画像を表示するには <img> タグを使う」のですが、「<img> の src 属性に画像の URL が指定されている」ため、ウェブページの HTML を見ることでどこに画像があるかが分かってしまうのです。ウェブページそのものをうまく作ることで右クリックを禁止する(右クリックのメニューからの「画像を保存」をできなくする)ことはできても、画像の URL がわかってしまうと、その URL を直接指定して開くことで「うまく作ったつもりの」ウェブページの仕組みを回避して保存できてしまうことになります。ウェブページの HTML を開けなくすることはできないため、「HTML を見られても画像の URL やデータがバレないようにする」必要がありました。

ややこしいことに「スクリーンショットで画像を保存する」ことへの対策も必要です。Windows や macOS のスクリーンショットで画像が表示されている状態のウェブページを丸ごとコピーして、画像アプリなどにペーストし、必要であれば画像部分を切り抜いて保存する方法です。この方法を使うと厳密な意味では画像の解像度なども変わってしまうのですが、そこまで気にしなければ(少なくともウェブや PC の画面で見る上では)ほぼ同じ画像を手に入れることができてしまいます。これは最早ウェブだけでどうにかできる話ではなく、OS レベルで対策が必要になるため、技術的にも非常に面倒な対策が必要になるものです。

そしてもっと面倒なこともあります。「開発者ツール」とか「Developer Tool」と呼ばれるウェブページ開発時に使われる確認ツール(ウェブブラウザの機能)があるのですが、これを使うと HTML 上ではわかりにくい画像(JavaScript を使ってプログラミングで動的に作成する画像)なども検知できてしまいます。ここまで含めて対策するのはかなり厄介といえます(なので私も対策の優先度を下げていました)。

以上のようにウェブで画像のコピーやダウンロードを禁止しようとすると、以下3つの対策が必要になります:
  1. HTML 内に画像の URL を残さない
  2. スクリーンショットへの対策
  3. 開発者ツールへの対策

【上記サイトでの対策方針】
先述した上記サイトではこれら3つの対策が全て実装されていました。内容を読んだ私の理解が正しければ以下のような方針が取られていました:

1. および 3. への対策

  • <img> タグを使わず、JavaScript を使って <canvas> タグ内に画像を描画して表示する
  • サーバー側で画像データを暗号化し、ブラウザ側で復号化し、データ URL("data:image/png;base64,xxxxxx...")形式で画像データを扱う
  • JavaScript は難読化する
  • (ここまでやればあまり意味はなく、ページ内でも「おまけ」と表現されていますが)ページ内で右クリックを無効化する JavaScript を実装する


2. への対策

  • オリジナル画像を表示せず、コピーライト文章を画像に上書きして表示する(上述の <canvas> 内にテキストを上書きして表示する)


まず目からウロコだったのは 2. への対策でした。表示される画像がオリジナルのものではないので(画像の右下にコピーライト文章が上書きされた画像なので)スクリーンショットを撮っても元画像は保存できない、という考え方でした。なるほど、その手があったか、、という印象でした。

加えて、<img> を使わずに(HTML5 の)<canvas> を使って画像を表示すること & 暗号化された画像データを受け取って復号化すること によって、HTML 内の情報から画像データを推測したり取り出したりすることを困難にする、ということも行っているようです。データ URL のテキストすら残さないやり方なので、かなり徹底した対策になっていると感じました。

一方で、同ページ内にも書かれていましたが、これでもまだ 100% の対策とは言えない要素もあります。JavaScript は難読化されているとはいえ解読不可能というわけではないため、暗号化したデータを復号化するコードを特定されてしまうと、同じロジックでデータを復号化して画像データを取り出し、その画像を保存する、ということは不可能ではないのでした。

また、これは私自身の印象なのですが、1枚の画像を守るためのページでこれだけの処理をするのはまだいいとして、画像が一覧で表示されるようなページで、全ての画像にこれだけの処理を実行して守った場合に、ページそのものの処理がかなり重くなってしまうのではないか?という懸念を感じました。例えば1ページ内に 100 枚の画像があったら、これだけの処理をブラウザ内で 100 回行う必要があるわけです。検索の仕組みなどがあると、検索結果となる画像全てにこの処理を行う必要もでてきます。今自分が開発・運営しているサービスの中でこの方法をそのまま実装するのは・・・ と感じています。


【自分で考えた対策方針】
では、実際に自分のサービスに実装する前提で、自分だったらどうやって画像のダウンロードを禁止するかを考えてみました。

ヒントとなったのは上述の 2. への対策(スクリーンショット対策)として書かれている方法でした。これはダウンロードそのものを禁止するとか、HTML からオリジナル画像にアクセスするための痕跡を消すとかいう考え方ではなく「そもそもオリジナル画像ではなく、コピーライト文章を上書きした画像を表示することで、オリジナル画像の保存を不可能にする」という方法および実装でした。これを自分のサービスの特性に合わせて応用する方法を思いつきました。

たまたま自分が実装を考えていたウェブアプリは「(IDとパスワードによるログインで)ユーザーの認証」を行うタイプのものでした。ログインしなくてもアプリを参照することはできますが、画像投稿はログイン後でないとできないタイプのものです。この場合は以下のような方法でもオリジナル画像のダウンロードを禁止することができるのではないかと考えました:

1. 画像の参照はオブジェクトストレージなどに格納して URL で参照するのではなく、画像の ID をパラメータに受け取って画像(バイナリ)情報を返す REST API の形で実現する。
2. ユーザーのログインをサーバー側でセッションとして管理する(セッション管理は必須ではないが、1. の REST API リクエストがどのユーザーからのものかを識別できるような仕組みを用意する)
3. 1. の REST API をリクエストしてきたユーザーが画像の持ち主と一致した場合は画像データをそのまま返す。リクエストユーザーが画像の持ち主とは別ユーザーだったり、そもそもログインしていないユーザーからのリクエストだった場合は画像データに加工(画像の一部にコピーライトメッセージを上書き)してから返す。

例えば画像 ID が xxxx である画像を表示するための REST API を "https://servername/attachment/xxxx" のように用意します。この URL にログイン済みのユーザーから、かつその画像の持ち主ユーザーからリクエストがあった場合のみ投稿された画像をそのまま返し、それ以外の場合は画像を加工してから返す、とすればいいのではないかと考えました。特定の画像を表示するための URL は(ログイン有無やユーザーの違いに関係なく)常に一意でいて、しかし返される画像のバイナリ情報はユーザーによって異なり、オリジナルの画像が返されるのは投稿ユーザーからのリクエストだった場合だけ、というものです。この方法だと同じ URL でも返される内容が異なるのでキャッシュは使えなくなりますが、既存のウェブアプリからの変更箇所は(REST API のパス部分に変更がないので、画像を返す REST API の中だけを変更し、セッション内のユーザーと画像の投稿者が同一であることを確認し、異なっていた場合のみコピーライトメッセージを上書きする処理を追加するだけで済むので)UI 側にほとんど変更がなく、比較的影響範囲が小さくなると思いました。

更にこの方法では、

- 画像を表示する方法はあくまで1つなので(パラメータも同じ REST API なので)、画像 URL を隠さなくても投稿者本人がログインした上でのリクエストでない限りはオリジナル画像を取り出すことができない(複雑な仕組みで画像 URL を隠す必要がない)

- 複数の画像が表示される一覧ページでも、1枚の画像が表示される詳細ページでも HTML や JavaScript に特別な違いを意識することなく同様に表示することができる

という副産物のメリットもあります。

加えて、これは私のウェブアプリでは 1 と 2 の部分はほぼ実装済みであったため、実質 3 の変更だけをすれば済む形になりました。自分の設計的にもこの方法が効率的だったという理由もあります。


【実装してみた】
実際に自分が運用しているお絵描き日記サービスでこの機能を有効にしてみました。興味ある方は実際に参照してみて(よかったら ID も作成してみて)ください。

まずログイン前にトップページを表示するとこのように表示されます(右上のボタンが黄色の場合はログイン前です)。表示される全てのお絵描き画像は画像を取り出す REST API 経由で取得しており、今回はログインしていないユーザーからのリクエストなので、全てのお絵描き画像にコピーライトメッセージ(図の青枠部分、実際の画像には青枠はありません)が付与されています。お絵描きの一覧ページでも、後述の個別ページでも同様です:
2023060400


ちなみにこのコピーライトメッセージ部分を拡大するとこのように表示されます。赤字で "© (投稿者の ID)@MyDoodles" と表示されています。"MyDoodles" はアプリ名です。オリジナルのお絵描き画像にはこのコピーライトメッセージはなく、API がこのメッセージをお絵描き画像の右下に上書きした上で結果を返しています:
2023060401


画面を下にスクロールすると色んな利用者から過去に投稿されたお絵描き画像が表示されていきます。この時点ではサービスにログインしていないので、全てのお絵描き画像にコピーライトメッセージが上書きされて表示されています:
2023060401


特定の1つのお絵描き画像を選択すると、そのお絵描き画像画像の詳細画面に移動します。このお絵描き画像は本当は私自身が投稿したものですが、まだログインしていないので(投稿者本人かどうか確認できないので)ここにもコピーライトメッセージが表示されています:
2023060402


別の利用者が投稿したお絵描き画像を詳細画面で表示した場合も、同様にコピーライトメッセージが上書きされた画像として表示されます:
2023060403



次にログイン後に同じページを参照してみます。画面右上の黄色いログインボタンをクリックしてログイン(またはサインアップしてからログイン)します:
2023060400


ログインに成功すると画面右上のボタンは緑になり、自分のアイコン画像に切り替わります。ログイン後の一覧画面では自分が投稿したお絵描き画像は(コピーライトメッセージが付与されず)オリジナル画像の状態で表示され、自分以外の利用者が投稿したお絵描き画像にはコピーライトメッセージが残る形で表示されます:
2023060401


下にスクロールして先ほどと同じ部分を見ています。上のお絵描き画像は私が投稿したお絵描きなので、今度はコピーライトメッセージが表示されていません。下のお絵描き画像は私以外の利用者が投稿したものなので、私の ID でログインしていてもコピーライトメッセージが表示されます:
2023060402


各お絵描き画像を詳細画面で見た場合です。私のお絵描き画像にはコピーライトメッセージがなく、他ユーザーのお絵描き画像にはコピーライトメッセージが上書きされて、それぞれ表示されていることがわかります:
2023060403



2023060404


繰り返しますがログイン前後で画像表示部分の HTML は変わっていませんが、ログインの有無によって同じ REST API の返り値が変わる形で実装しているので、画像データそのものの取得はシンプル(<img src="xxxx" /> 部分はログイン前後で全く同じ)です。しかしここがシンプルなだけに、画像表示部分の HTML や JavaScript を見られたとしても、投稿したユーザーの ID でログインしない限りはオリジナル画像を取得する方法がない、という形で実装できたことになります。


最初は「どうやってオリジナル画像の情報を隠そうか」と色々な実現方法を考えていたのですが、スクリーンショット対策の「オリジナル画像ではない画像を表示する」というアイデアを応用させる形でこの方法を思いつきました。この方法は上でも書いている通り「キャッシュを有効にできない」という弱点はあるものの、(自分のように笑)そこまでアクセスの多くないログイン付きサービスで、API 取得以外の方法で画像にアクセスできないようにすることができれば、投稿者本人だけはオリジナル画像に簡単にアクセスできるというメリットまで含めて、この実装方法はかなり現実的なものになり得ると考えています。

これで投稿者のオリジナル画像の無断コピーを(投稿者本人以外からは)絶つことができそう、と思う。近いうちにマンホールマップにもこの実装を移植しよ。

※回避できるツワモノがいらっしゃったら教えてください。 m(__)m