1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-07-17

概要

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.

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

使用ツール

node.js 16.3.0
discord.js 13.0.0-dev.5ca97c9.1626480265
Discord Canary 90336 (36fb009) Host 1.0.37 Windows 10 64-Bit (10.0.19043)
REST Client
Google Chrome 91.0.4472.164
React Developer Tools 4.13.5
使用したコードは一応GitHubに上げてある

検証用のbot

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

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

ボタン

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

{
   "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に渡ってきた情報である。

{
  "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

{
   "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

{
  "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

{
   "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

{
  "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と言われてしまった。

{
   "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/"
   }
}
{
  "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をつかってReactのvirtual domを見てhashを見つけた。

image.png
image.png

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

どうやら、hashdvahpeBNらしいので実行してみる。

{
   "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"
   }
}

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

{
  "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が返ってくる。

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

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

削除されたコンポーネントへのインタラクション

テスト用のBotを以下のように変更した。

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

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

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

{
   "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なメッセージでもうまく動作するようにするため。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?