discordのMessage Componentsで遊ぶときの注意事項

# 概要
Message Componentsを扱うときに、discordからの情報をすべて信用していると痛い目を見るよという話。
以下のように言われているけど具体的にどういうことなのか?というのを調べてみた(調べ方を書いた)記事です。
> Your application should take care to validate data sent in component interactions. For example, ensuring that the custom_id originates from the received message. In the future this information will be validated by the API.

https://discord.com/developers/docs/interactions/message-components#responding-to-a-component-interaction

当然、ここで触れられている内容が全てではないし、今後、APIが変更され、状態が変化することもありえます。

# 使用ツール
node.js 16.3.0
[discord.js 13.0.0-dev.5ca97c9.1626480265](https://www.npmjs.com/package/discord.js/v/13.0.0-dev.5ca97c9.1626480265)
[Discord Canary 90336 (36fb009) Host 1.0.37 Windows 10 64-Bit (10.0.19043)](https://discordapp.com/api/download/canary?platform=win)
[REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client)
Google Chrome 91.0.4472.164
React Developer Tools 4.13.5 
使用したコードは一応[GitHubに上げてある](https://github.com/tignear/discord-message-components-security)

# 検証用のbot
```js
const Discord = require("discord.js");
const { inspect } = require("util");
const client = new Discord.Client({
  intents: 0
});
async function placeComponents() {
  const guild = await client.guilds.fetch("750031320205230311");
  /**@type {Discord.TextChannel} */
  const channel = await guild.channels.fetch("750031320205230314", {
    allowUnknownGuild: true,
  });
  await channel.send({
    content: "test",
    components: [
      new Discord.MessageActionRow().addComponents(new Discord.MessageButton({
        label: "enabled",
        customId: "enabled-button",
        style: "PRIMARY",
      }), new Discord.MessageButton({
        label: "disabled",
        customId: "disabled-button",
        style: "SECONDARY",
        disabled: true,
      })),
      new Discord.MessageActionRow().addComponents(new Discord.MessageSelectMenu({
        customId: "select",
        maxValues: 2,
        minValues: 1,
        options: new Array(25).fill(null).map((_, i) => i).map(e => ({
          label: String(e),
          value: String(e)
        }))
      }))
    ]
  });
}
client.once("ready", () => {
  client.on("interactionCreate", (interaction) => {
    console.log(JSON.stringify(interaction.toJSON(), undefined, 2));
    interaction.reply({content: "received", ephemeral: true});
  });
  console.log("ready");
  placeComponents().catch(console.error);
});
client.login();


```
上記のbotを起動すると、指定のチャンネルにこのようなメッセージが投稿される。このメッセージを使っていろいろ試していく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131375/394c429c-116a-c8ef-e954-0dc45ace3043.png)


# クライアントからdiscordへどのように情報が飛び、その後discordからサーバーへどのように情報が飛ぶのか観察する

## ボタン
PC版クライアントを起動し、開発者ツールを開く。
`enabled`というラベルのついたボタンを押してみる。
以下はその際のリクエストの、重要な部分のみを取り出したものだ。
`hash`とかついていて良さそうに思える。(本当に?)

```json
{
   "type":3,
   "guild_id":"750031320205230311",
   "channel_id":"750031320205230314",
   "message_flags":0,
   "message_id":"865823867905638400",
   "application_id":"735835853372522546",
   "data":{
      "component_type":2,
      "custom_id":"enabled-button",
      "hash":"p2RHj0S/"
   }
}
```

以下は`discord.js`の表現になってはいるが、botに渡ってきた情報である。

```json
{
  "type": "MESSAGE_COMPONENT",
  "id": "865824938316267530",
  "applicationId": "735835853372522546",
  "channelId": "750031320205230314",
  "guildId": "750031320205230311",
  "user": "408939071289688064",
  "member": "408939071289688064",
  "version": 1,
  "message": "865824923619557376",
  "customId": "enabled-button",
  "componentType": "BUTTON",
  "deferred": false,
  "ephemeral": null,
  "replied": false
}
```

## セレクトメニュー
クライアント -> Discord API

```json
{
   "type":3,
   "guild_id":"750031320205230311",
   "channel_id":"750031320205230314",
   "message_flags":0,
   "message_id":"865828647590690816",
   "application_id":"735835853372522546",
   "data":{
      "component_type":3,
      "custom_id":"select",
      "hash":"ndp3LH8f",
      "values":[
         "1",
         "2"
      ]
   }
}
```

Discord API -> Bot

```json
{
  "type": "MESSAGE_COMPONENT",
  "id": "865828772070031360",
  "applicationId": "735835853372522546",
  "channelId": "750031320205230314",
  "guildId": "750031320205230311",
  "user": "408939071289688064",
  "member": "408939071289688064",
  "version": 1,
  "message": "865828647590690816",
  "customId": "select",
  "componentType": "SELECT_MENU",
  "deferred": false,
  "ephemeral": null,
  "replied": false,
  "values": [
    "1",
    "2"
  ]
}
```

ぱっと見て`hash`の保護が`values`には及んでいないように見える。
また、`mix_values`および、`max_values`がリクエストには含まれていないので、これらも、サーバー側で検証されていないのではという疑惑が生まれる。
そういえば、ボタンの`disabled`はどうなっているのだろうということも疑問に思えてくる。
これらのことを確かめてみよう。

# セレクトメニューのvaluesを改ざんしてみる
面倒なので、`values`に3つの値を入れ、3つ目の値はBotからDiscord APIに与えられたものではないものにした。

Client -> Discord API

```json
{
   "type":3,
   "guild_id":"750031320205230311",
   "channel_id":"750031320205230314",
   "message_flags":0,
   "message_id":"865828647590690816",
   "application_id":"735835853372522546",
   "data":{
      "component_type":3,
      "custom_id":"select",
      "hash":"ndp3LH8f",
      "values":[
         "1",
         "2",
         "100"
      ]
   }
}
```

Discord API -> Bot

```json
{
  "type": "MESSAGE_COMPONENT",
  "id": "865831187271122974",
  "applicationId": "735835853372522546",
  "channelId": "750031320205230314",
  "guildId": "750031320205230311",
  "user": "408939071289688064",
  "member": "408939071289688064",
  "version": 1,
  "message": "865828647590690816",
  "customId": "select",
  "componentType": "SELECT_MENU",
  "deferred": false,
  "ephemeral": null,
  "replied": false,
  "values": [
    "1",
    "2",
    "100"
  ]
}
```

# disabledなボタンを押してみる
とりあえず`custom_id`だけ書き換えたところ、`400`と言われてしまった。


```json
{
   "type":3,
   "guild_id":"750031320205230311",
   "channel_id":"750031320205230314",
   "message_flags":0,
   "message_id":"865823867905638400",
   "application_id":"735835853372522546",
   "data":{
      "component_type":2,
      "custom_id":"disabled-button",
      "hash":"p2RHj0S/"
   }
}
```

```json
{
  "code": 50035,
  "errors": {
    "data": {
      "hash": {
        "_errors": [
          {
            "code": "COMPONENT_HASH_INVALID",
            "message": "The specified component hash is invalid"
          }
        ]
      }
    }
  },
  "message": "Invalid Form Body"
}
```

仕方がないので、`disabled`なボタンの`hash`を探すことにした。
プロはDOMとminifyされたjsを見て、あるボタンの`hash`を探すことができるのかもしれないが、残念なことにプロではないのでここではweb版のdiscordと[React Dev Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=ja)をつかってReactのvirtual domを見て`hash`を見つけた。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131375/c6741047-3194-6c51-4f40-a9800ad68356.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131375/5d4e0e3b-b5f8-8c36-9c4e-013c4e1d5134.png)

```json
{
  "message": {
    "id": "865823867905638400",
    "channel_id": "750031320205230314",
  },
  "type": 2,
  "customId": "disabled-button",
  "hash": "dvahpeBN",
  "style": 2,
  "disabled": true,
  "label": "disabled",
  "applicationId": "735835853372522546"
}
```

どうやら、`hash`は`dvahpeBN`らしいので実行してみる。

```json
{
   "type":3,
   "guild_id":"750031320205230311",
   "channel_id":"750031320205230314",
   "message_flags":0,
   "message_id":"865823867905638400",
   "application_id":"735835853372522546",
   "data":{
      "component_type":2,
      "custom_id":"disabled-button",
      "hash":"dvahpeBN"
   }
}
```

何事もなかったかのように通る。

```json
{
  "type": "MESSAGE_COMPONENT",
  "id": "865836155001372672",
  "applicationId": "735835853372522546",
  "channelId": "750031320205230314",
  "guildId": "750031320205230311",
  "user": "408939071289688064",
  "member": "408939071289688064",
  "version": 1,
  "message": "865823867905638400",
  "customId": "disabled-button",
  "componentType": "BUTTON",
  "deferred": false,
  "ephemeral": null,
  "replied": false
}
```

こうもやりたい放題できると飽きてくるので、少しだけ毛色が違うことをやって終わりにすることにする。

# 削除されたメッセージへのインタラクション
流石に`404`が返ってくる。

```json
{
  "message": "Unknown Message",
  "code": 10008
}
```

では、削除された`components`へのインタラクションは?

# 削除されたコンポーネントへのインタラクション
テスト用のBotを以下のように変更した。

```js
const Discord = require("discord.js");
const { inspect } = require("util");
const client = new Discord.Client({
  intents: Discord.Intents.FLAGS.GUILD_MESSAGES
});
async function placeComponents() {
  const guild = await client.guilds.fetch("750031320205230311");
  /**@type {Discord.TextChannel} */
  const channel = await guild.channels.fetch("750031320205230314", {
    allowUnknownGuild: true,
  });
  const placed = await channel.send({
    content: "test",
    components: [
      new Discord.MessageActionRow().addComponents(new Discord.MessageButton({
        label: "enabled",
        customId: "enabled-button",
        style: "PRIMARY",
      }), new Discord.MessageButton({
        label: "disabled",
        customId: "disabled-button",
        style: "SECONDARY",
        disabled: true,
      })),
      new Discord.MessageActionRow().addComponents(new Discord.MessageSelectMenu({
        customId: "select",
        maxValues: 2,
        minValues: 1,
        options: new Array(25).fill(null).map((_, i) => i).map(e => ({
          label: String(e),
          value: String(e)
        }))
      }))
    ]
  });
  await channel.awaitMessages({
    filter: msg => msg.content === "update",
    max: 1
  });
  await placed.edit({
    content: "close",
    components: []
  });
}
client.once("ready", () => {
  client.on("interactionCreate", (interaction) => {
    console.log(JSON.stringify(interaction.toJSON(), undefined, 2));
    interaction.reply({ content: "received", ephemeral: true });
  });
  console.log("ready");
  placeComponents().catch(console.error);
});
client.login();
```

その上で、以下のような結果を得た。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131375/bae4f1bf-857a-12de-0b16-ea7076bb46b0.png)

 - 1つ目はボタンをクライアントから押した際の応答。
 - 2つ目はメッセージの更新前にリクエストをRest Client経由で、送信した際の応答。
 - 3つ目はメッセージの更新後にリクエストをRest Client経由で、送信した際の応答。

Rest Client経由で、送信したリクエストは次の通りで、通常通りにボタンを実行していたときと変化はない。

```json
{
   "type":3,
   "guild_id":"750031320205230311",
   "channel_id":"750031320205230314",
   "message_flags":0,
   "message_id":"865840403311755272",
   "application_id":"735835853372522546",
   "data":{
      "component_type":2,
      "custom_id":"enabled-button",
      "hash":"YEEU+JRk"
   }
}
```

# まとめ
外部システムからの入力を信頼してはいけないというのはよく言われていることだが、特に、Message Componentsから渡ってくる情報の扱いには気をつけよう。

# なんでこんなことになってるの?
個人的な推測にはなるけれど、たぶんephemeralなメッセージでもうまく動作するようにするため。