Cloudflare Streamでプレイヤーの埋め込みドメインを制限してみた

Allowed Originsを指定することでStream Playerを参照可能なドメインを制限することができます。対象外のドメインからではPlayerの動画再生は機能せず、またHLSマニフェストURLではCORSエラーが発生しました。
2022.06.30

はじめに

清水です。Cloudflareの動画配信プラットフォームのサービスCloudflare Streamを試しています。Cloudflare Streamの動画コンテンツ保護機能の1つとして、プレイヤーの埋め込み先ドメインを指定する機能があります。許可されたドメインのページに埋め込まれたStream Playerだけが対象の動画コンテンツを再生できる、というわけです。ドキュメントでは「Hotlinking Protection」と呼ばれ、またダッシュボードなど設定画面では「許可されたオリジン/Allowed Origins」と表記されています。本エントリではこのHotlinking Protection/許可対象のオリジン指定について、実際に試しつつ、どのような仕組みで制限しているのか推測してみたのでまとめてみたいと思います。Allowed Originsの名称からも推測できるように、おそらくCORSのためのAccess-Control-Allow-Originヘッダを利用する、というような仕組みで制限していると推測しています。

Hotlinking Protectionの有効化

まずはダッシュボードからHotlinking Protectionを有効にします。Hotlinking Protectionはビデオごとに設定していきます。ダッシュボードでビデオの詳細ページに進み、「設定」タブの「許可されたオリジン」の箇所に、プレイヤーやマニフェストURLの埋め込みを許可するドメインを記載します。ドキュメント「Hotlinking Protection - Securing your Stream · Cloudflare Stream docs」には記載の際の注意事項として以下が挙げられます。

  • *.example.comではexample.com(ネイキッドドメイン)とwww.example.com(サブドメイン)、そしてaa.bb.example.com(サブサブドメイン)がサポートされる
  • example.comではwww.example.comなどのサブドメインはサポートされない
  • パスはサポートされない。example.comを指定した場合はexample.com/*として扱われる

今回はstream-player.example.netを指定しました。

またAPIでもHotlinking Protection、埋め込みを許可するドメイン(オリジン)の設定は可能です。以下の形式でJSONデータをPOSTします。

curl -X POST \
     -H "Authorization: Bearer ${TOKEN}" \
     --data "{\"uid\":\"${VIDEO_UID}\", \"allowedOrigins\": [\"stream-player.example.net\"]}" \
     "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT}/stream/${VIDEO_UID}"

以下が実際の実行例です。設定後、"requireSignedURLs": trueとなっていることが確認できます。

##### 設定前の状態を確認
 % curl -X GET -H "Authorization: Bearer ${TOKEN}" "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT}/stream/${VIDEO_UID}"
{
  "result": {
    "uid": "bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "creator": "user-musashino",
    "thumbnail": "https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/thumbnails/thumbnail.jpg",
    "thumbnailTimestampPct": 0,
    "readyToStream": true,
    "status": {
      "state": "ready",
      "pctComplete": "100.000000",
      "errorReasonCode": "",
      "errorReasonText": ""
    },
    "meta": {
      "downloaded-from": "https://mybucket.s3.ap-northeast-1.amazonaws.com/IMG_3435.MOV",
      "name": "吉祥寺の駅"
    },
    "created": "2022-04-30T10:53:00.119086Z",
    "modified": "2022-06-29T13:37:08.702328Z",
    "size": 336065892,
    "preview": "https://watch.videodelivery.net/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "allowedOrigins": [],
    "requireSignedURLs": false,
    "uploaded": "2022-04-30T10:53:00.119078Z",
    "uploadExpiry": null,
    "maxSizeBytes": null,
    "maxDurationSeconds": null,
    "duration": 53.8,
    "input": {
      "width": 3840,
      "height": 2160
    },
    "playback": {
      "hls": "https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/manifest/video.m3u8",
      "dash": "https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/manifest/video.mpd"
    },
    "watermark": null
  },
  "success": true,
  "errors": [],
  "messages": []
}


##### Hotlinking Protectionを有効化
 % curl -X POST \
     -H "Authorization: Bearer ${TOKEN}" \
     --data "{\"uid\":\"${VIDEO_UID}\", \"allowedOrigins\": [\"stream-player.example.net\"]}" \
     "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT}/stream/${VIDEO_UID}"
{
  "result": {
    "uid": "bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "creator": "user-musashino",
    "thumbnail": "https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/thumbnails/thumbnail.jpg",
    "thumbnailTimestampPct": 0,
    "readyToStream": true,
    "status": {
      "state": "ready",
      "pctComplete": "100.000000",
      "errorReasonCode": "",
      "errorReasonText": ""
    },
    "meta": {
      "downloaded-from": "https://mybucket.s3.ap-northeast-1.amazonaws.com/IMG_3435.MOV",
      "name": "吉祥寺の駅"
    },
    "created": "2022-04-30T10:53:00.119086Z",
    "modified": "2022-06-29T13:40:34.063972Z",
    "size": 336065892,
    "preview": "https://watch.videodelivery.net/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "allowedOrigins": [
      "stream-player.example.net"
    ],
    "requireSignedURLs": false,
    "uploaded": "2022-04-30T10:53:00.119078Z",
    "uploadExpiry": null,
    "maxSizeBytes": null,
    "maxDurationSeconds": null,
    "duration": 53.8,
    "input": {
      "width": 3840,
      "height": 2160
    },
    "playback": {
      "hls": "https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/manifest/video.m3u8",
      "dash": "https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/manifest/video.mpd"
    },
    "watermark": null
  },
  "success": true,
  "errors": [],
  "messages": []
}

##### 設定後の状態を確認
 % curl -X GET -H "Authorization: Bearer ${TOKEN}" "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT}/stream/${VIDEO_UID}"                               {
  "result": {
    "uid": "bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "creator": "user-musashino",
    "thumbnail": "https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/thumbnails/thumbnail.jpg",
    "thumbnailTimestampPct": 0,
    "readyToStream": true,
    "status": {
      "state": "ready",
      "pctComplete": "100.000000",
      "errorReasonCode": "",
      "errorReasonText": ""
    },
    "meta": {
      "downloaded-from": "https://mybucket.s3.ap-northeast-1.amazonaws.com/IMG_3435.MOV",
      "name": "吉祥寺の駅"
    },
    "created": "2022-04-30T10:53:00.119086Z",
    "modified": "2022-06-29T13:40:34.063972Z",
    "size": 336065892,
    "preview": "https://watch.videodelivery.net/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "allowedOrigins": [
      "stream-player.example.net"
    ],
    "requireSignedURLs": false,
    "uploaded": "2022-04-30T10:53:00.119078Z",
    "uploadExpiry": null,
    "maxSizeBytes": null,
    "maxDurationSeconds": null,
    "duration": 53.8,
    "input": {
      "width": 3840,
      "height": 2160
    },
    "playback": {
      "hls": "https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/manifest/video.m3u8",
      "dash": "https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/manifest/video.mpd"
    },
    "watermark": null
  },
  "success": true,
  "errors": [],
  "messages": []
}

指定したドメインに埋め込んだプレイヤーで再生できることの確認

Hotlinking Protection設定後、まずは実際に許可したドメインのページで埋め込んだStream Playerにて動画が再生できることを確認してみましょう。stream-player.example.netのドメイン配下に、以下のstream-player.htmlのページを作成しました。

stream-player.html

<html>
  <head>
    <title>Stream Player</title>
  </head>
  <body>
    <div style="position: relative; padding-top: 56.25%">
      <iframe
         src="https://iframe.videodelivery.net/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
         style="border: none; position: absolute; top: 0; height: 100%; width: 100%"
         allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
         allowfullscreen="true"
         ></iframe>
    </div>
  </body>
</html>

いざ、Google Chromeブラウザでhttps://stream-player.example.net/stream-player.htmlを開いて再生してみます。問題なく再生ができていますね!

指定していないドメインでは再生できないことの確認

続いて、埋め込みを許可していないドメインページで確認してみましょう。not-allowed-origin.example.infoというドメインを準備、配下に先程と同様、stream-player.htmlのページを配置しました。https://not-allowed-origin.example.info/stream-player.htmlにアクセスしてみます。「This video has not been configured to be allowed on this domain.」と表示され、動画は再生できませんね。

またこのとき、iframe.videodelivery.netからは403のステータスコードが返ってきていました。

もう一つ、Stream Playerのiframeのドメインiframe.videodelivery.netでの再生についても確認しておきましょう。https://iframe.videodelivery.net/$VIDEOIDのURLを直接ブラウザで開いてみます。こちらも再生できません。先ほどと同じく「This video has not been configured to be allowed on this domain.」とメッセージが表示されています。

こちらも確認すると、403 Forbiddenが返ってきていました。

HLSマニフェストURLを使用した場合の挙動の確認

HLSマニフェストURL使用時の再生可否

設定したとおり、許可したドメインではStream Playerでの再生ができ、それ以外のドメインでは再生ができないという状況でした。ここで、Stream PlayerではなくマニフェストURLを使って独自プレイヤーで再生する場合の挙動も確認してみましょう。例として、VideoJS HTTP Streaming (VHS)でHLSマニフェストURLを入力して再生しようと試みます。が、videojs.github.ioドメインでの再生になることからか再生ができません。確認してみるとCORSエラーが発生している状況でした。

CORSエラーが発生している、ということはJavaScriptを使用しないケースでは再生できるかもしれませんね。ためしにHLSのネイティブ再生(JavaScript製プレイヤーではなく、ブラウザのプレイヤーで再生)に対応しているmacOS上のSafariブラウザで確認してみたところ、こちらは再生が可能でした。

ということで、ドメインの制御としてCORSで許可する・しないを制限している、という推測ができます。

HLSマニフェストURLのAccess-Control-Allow-Originヘッダの確認

CORSでの制御という推測を裏付けるため、Access-Control-Allow-Originヘッダについても確認してみます。まずはHLSマニフェストURLに対してcurlコマンドでヘッダ情報だけ確認してみます。こちらはaccess-control-allow-origin: *ということで、予想と異なりどんなドメイン(オリジン)からでもCORSが許可されていそうです。

 % curl -I https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/manifest/video.m3u8
HTTP/2 200
date: Wed, 29 Jun 2022 14:04:47 GMT
content-type: application/x-mpegURL
access-control-allow-origin: *
cache-control: public, max-age=600
vary: origin, referer
access-control-allow-headers: range
access-control-expose-header: cf-ray
stream-dw-version: 2022.6.2
expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
server: cloudflare
cf-ray: 722f369d6b8a344b-NRT

それでは、と試しに許可されていないドメインをリクエスト時のOriginヘッダに指定してみます。今度はレスポンスにaccess-control-allow-origin: https://videodelivery.netというヘッダが付与されました。このvideodelivery.netドメインはCloudflare Streamで利用されているものです。結果、Originヘッダに指定したドメインからはCORSエラーが発生することになります。

 % curl -I -H "Origin: not-allowed-origin.example.info" https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/manifest/video.m3u8
HTTP/2 200
date: Wed, 29 Jun 2022 14:08:56 GMT
content-type: application/x-mpegURL
access-control-allow-origin: https://videodelivery.net
cache-control: public, max-age=600
vary: origin, referer
access-control-allow-headers: range
access-control-expose-header: cf-ray
stream-dw-version: 2022.6.2
expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
server: cloudflare
cf-ray: 722f3caba93caf81-NRT

許可したドメインでも試してみましょう。"Origin: stream-player.example.net"をリクエスト時のヘッダに付与してみます。レスポンスにはこのドメインがaccess-control-allow-originヘッダに含まれる結果となりました。これならCORSに対応し動画の再生が可能になります。

 % curl -I -H "Origin: stream-player.example.net" https://cloudflarestream.com/bbd3xxxxxxxxxxxxxxxxxxxxxxxxxxxx/manifest/video.m3u8
HTTP/2 200
date: Wed, 29 Jun 2022 14:09:22 GMT
content-type: application/x-mpegURL
access-control-allow-origin: stream-player.example.net
cache-control: public, max-age=600
vary: origin, referer
access-control-allow-headers: range
access-control-expose-header: cf-ray
stream-dw-version: 2022.6.2
expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
server: cloudflare
cf-ray: 722f3d4fcd19afe1-NRT

あくまでHLSマニフェストURLを対象にした挙動ですが、リクエスト時のOriginヘッダをもとにレスポンスのAccess-Control-Allow-Originヘッダを調整し、再生(CORS)可能なドメインを制限している、と推測しています。マニフェストURLではなくStream Playerの場合は403エラーということでまったく同じではないかと思いますが、このようにリクエスト時の(iframe埋め込みもと?)ドメインを確認してレスポンスのステータスや内容を変更しているのでは、と推測しています。

まとめ

Cloudflare StreamのHotlinking Protection機能について実際に試しつつ、挙動などからその仕組みを推測してみました。ドキュメントに具体的な記載はありませんでしたが、HLSマニフェストURLの場合はレスポンスのAccess-Control-Allow-Originヘッダの挙動からCORSを利用して許可したドメインのみから再生可能にしているようです。Stream Playerの場合もリクエスト時の埋め込みもとドメインの情報からレスポンスのステータスを決定、再生制限していると予想しています。

ドキュメント「Securing your Stream」の項目には、このHotlinking Protection、Playerの埋め込み可能なドメインの指定とあわせて署名付きURLを利用することで、よりセキュアに動画の視聴制限を実現できるとしています。実際の本番運用にあたってはこのHotlinking Protectionも是非とも設定しておきましょう。