概要
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を起動すると、指定のチャンネルにこのようなメッセージが投稿される。このメッセージを使っていろいろ試していく。
クライアントから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
を見つけた。
{
"message": {
"id": "865823867905638400",
"channel_id": "750031320205230314",
},
"type": 2,
"customId": "disabled-button",
"hash": "dvahpeBN",
"style": 2,
"disabled": true,
"label": "disabled",
"applicationId": "735835853372522546"
}
どうやら、hash
はdvahpeBN
らしいので実行してみる。
{
"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();
- 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なメッセージでもうまく動作するようにするため。