iframeからしか読み込まれないように制限する？！

## はじめに

iframeからサイトが読み込まれることを制限するには、`X-Frame-Options`や`Content-Security-Policy(CSP)`を利用すればよい。

今回はそうではなく、逆にiframeからのみからしか読み込みをしないことを制限したいという要件が出てきたので、その対応方法としてやってみたことを紹介してみたいと思う。

## Why

そもそもiframeからしか読み込みを許可しない、という要件がどのような要件か？について軽く触れておきたいと思う。

Difyを利用すると簡単にRAGと組み合わせたチャットボットを作成できる。そしてチャットのURLをiframeで埋め込みすることで、簡単にチャットUIも表示できるようになる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/e0ef6c68-7ddf-d39f-0020-751c920d3265.png)

ただ、iframeのURLをブラウザに張り付ければ普通にチャット画面を開くことができてしまうので、仮にユーザーのロール別に利用できるチャットボットが制限されているような場面では、iframeのURLを他の人に共有されてしまうと、本来利用できないユーザーも利用できてしまうという事が発生してしまう。

そんなわけで、利用できるユーザーを制限するという要件（iframe埋め込みの画面が開けない）を実現する必要性が出てきた。

## 実現方法

結論としては、JavaScriptの`postMessage`を使用する、を利用した。

親ページからiframeに対しては、以下のようにメッセージイベントを送信でき、iframeのサイトではそれをリッスンすることができる。

```js
const iframe = document.getElementById('myIframe');
iframe.contentWindow.postMessage('送信するデータ', 'https://iframeのドメイン');
```

```js
window.addEventListener('message', function(event) {
    if (event.origin !== 'https://親ページのドメイン') {
        return; // 信頼できないソースからのメッセージを無視
    }
    const data = event.data;
    // データを処理する
});
```

今回はこの仕組みを利用して、iframeを読み込むサイトからDifyに対してメッセージを送り、それをDifyの方で検証してチャット画面を開くのか、エラーにするのかを制御する方法を取った。

※もともと、DifyをForkしていくつかの機能を追加していたので今回の方法でもOKという判断に。

### 具体的な実装

今回、iframeでDifyのチャット画面を埋め込みたかったサイトはVue.jsで実装されていたので、iframe埋め込み側は以下のような実装になった。

```vue
<template>
    <div>
        <iframe
            ref="iframe"
            src="https://..../chatbot/...."
            style="width: 100%; height: 100%; min-height: 500px"
            frameborder="0"
            allow="microphone"
        />
    </div>
</template>
<script>
export default {
    mounted() {
        this.$refs.iframe.addEventListener('load', () => {
            this.sendMessageToIframe();
        });
    },
    methods: {
        sendMessageToIframe() {
            // iframeにメッセージを送信タイミングを遅らせないとうまくいかない
            // ロードとDifyの画面描画のタイミングの違いが原因かもしれない
            setTimeout(() => {
                this.$refs.iframe.contentWindow.postMessage({ type: 'AUTHORIZATION', isAuthorized: true }, '*');
            }, 100);
        }
    }
};
</script>
```

また、Difyの方の実装は以下のようになった。

```react:web/app/components/base/chat/embedded-chatbot/index.tsx
import React, { useEffect, useState } from 'react';

const Chatbot = () => {
  const [isAuthorized, setIsAuthorized] = useState<null | boolean>(null);

  useEffect(() => {
    function handleMessage(event: MessageEvent) {
      const data = event.data;
      if (data && data.type === 'AUTHORIZATION' && data.isAuthorized) {
        setIsAuthorized(data.isAuthorized);
      } else {
        setIsAuthorized(false);
      }
    }

    window.addEventListener('message', handleMessage);
    // タイムアウトを設定
    const timeoutId = setTimeout(() => {
      if (!isAuthorized) setIsAuthorized(false);
    }, 5000);
    
    return () => {
      window.removeEventListener('message', handleMessage);
      clearTimeout(timeoutId);
    };
  }, [isAuthorized]);

  if (isAuthorized === false) {
    // 403エラーページを表示
    return <div>403 Forbidden</div>;
  }

  return ...
};

export default Chatbot;
```

Dify側はメッセージイベントを受け取り、そのデータの内容に基づいて画面の出し分けをする。

※ここでは簡単な例としてべた書きの固定値で検証するようなコードにしているが、実際には公開鍵・秘密鍵等でやり取りするメッセージを暗号化（親ページ側で暗号化しiframeのサイトで復号）するというような方法を取ることで、不正な方法で送信されたメッセージイベントを信頼しない、というような実装をすべきかもしれない。iframeを読み込めるサイトの制限はするとしても。

## まとめとして

今回はiframeで読み込まれるサイトを制限するのではなく、iframeのみからしか読み込まれないように制限する方法についてみてきた。実際にこのような要件が出てくることは少ないのかもしれないが、Webでのアクセス制限というと…で思いつくCookieやHTTPヘッダー等を利用できない（iframeからのアクセスを識別する方法はクエリパラメータ等以外ではreferなど偽造しやすいものしかない）ため、ひとひねり必要だと思われる。

何かの参考になれば幸いです。