From e5cb8c6bd4345930e3bfcce0b9a30cacbb792917 Mon Sep 17 00:00:00 2001 From: TomatoCake <60300461+DEVTomatoCake@users.noreply.github.com> Date: Thu, 2 Nov 2023 10:06:18 +0100 Subject: [PATCH] send webhooks & filter event/action & validate secret --- README.md | 10 ++++++++ bot.js | 9 ++++++- index.js | 63 +++++++++++++++++++++++++++++++++++++++++++------ util/oauth.js | 27 +++++++++++---------- util/setupDB.js | 14 ++++++----- 5 files changed, 96 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 98a41f4..9b8fc0b 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,13 @@ This repository contains the backend code for the DisGitHook project. It contains the API for the website and the code for handling webhooks. + +## API routes + +- / +- /login +- /logout +- /servers +- /server/:id (get & set settings) +- POST /hook/:id & Authorization header +- POST /hook/:id/:secret diff --git a/bot.js b/bot.js index 7c93c04..f07f079 100644 --- a/bot.js +++ b/bot.js @@ -12,7 +12,14 @@ const bot = new Discord.Client({ const { botToken } = require("./config.json") bot.login(botToken) +module.exports = bot + +const pool = require("./util/setupDB.js") bot.on("ready", () => { - bot.user.setPresence({activities: [{name: "Custom Status", state: "Wooooo!", type: Discord.ActivityType.Custom}], status: "dnd"}) + bot.user.setPresence({activities: [{name: "Custom Status", state: "Customizable GitHub hooks!", type: Discord.ActivityType.Custom}], status: "dnd"}) +}) + +bot.on("guildDelete", guild => { + pool.query("DELETE FROM `hook` WHERE `guild` = ?", [guild.id]) }) diff --git a/index.js b/index.js index 2d543fa..e9a467e 100644 --- a/index.js +++ b/index.js @@ -1,15 +1,18 @@ const Discord = require("discord.js") -require("./bot.js") +const bot = require("./bot.js") const { botId, botSecret, userAgent, domain, port, cookieSecret } = require("./config.json") const oauth = require("./util/oauth.js") const pool = require("./util/setupDB.js") +const crypto = require("node:crypto") const express = require("express") const cookieParser = require("cookie-parser") const app = express() +app.disable("x-powered-by") +app.use(express.json()) app.use(cookieParser(cookieSecret)) app.listen(port) @@ -22,10 +25,10 @@ app.get("/", (req, res) => { app.get("/servers", async (req, res) => { if (!req.signedCookies.token) return res.status(401).send("Missing token cookie") - const servers = await oauth.getUserServers() + const servers = await oauth.getUserServers(req.signedCookies.token, pool) console.log(servers) - const filtered = servers.filter(server => Discord.PermissionsBitField(server.permissions).has("ManageGuild")).map(server => ({ + const filtered = servers.filter(server => server.owner || Discord.PermissionsBitField(server.permissions).has("ManageGuild")).map(server => ({ id: server.id, name: server.name, icon: server.icon @@ -89,11 +92,57 @@ app.get("/logout", (req, res) => { const hookFunc = async (req, res) => { const [rows] = await pool.query("SELECT * FROM `hook` WHERE `id` = ?", [req.params.id]) - console.log(rows) - if (rows.length == 0) return res.sendStatus(404) - res.sendStatus(204) + const hook = rows[0] + console.log(req.body) + + if (req.params.secret.startsWith("sha256=")) { + const sha256 = crypto.createHmac("sha256", hook.secret) + if (!crypto.timingSafeEqual(Buffer.from("sha256=" + sha256.update(JSON.stringify(req.body)).digest("hex")), Buffer.from(req.params.secret))) return res.status(401).send("Invalid secret as header") + } else if (req.params.secret != hook.secret) return res.status(401).send("Invalid secret in URL") + + const githubEvent = req.headers["x-github-event"] + if (hook.filterEvent && !hook.filterEvent.includes(githubEvent)) return res.status(202).send("Event " + githubEvent + " is disabled in settings for this hook") + + const data = req.body + const action = data.action + if (hook.filterAction && !hook.filterAction.includes(action)) return res.status(202).send("Action " + action + " is disabled in settings for this hook") + + let message = hook.message + const recursiveFunc = (obj, path = "") => { + for (const property in obj) { + if (typeof obj[property] == "object") recursiveFunc(obj[property], path + property + ".") + else message = message.replace(new RegExp("{" + path + property + "}", "gi"), obj[property]) + } + } + recursiveFunc(data) + + let parsed = {} + try { + parsed = JSON.parse(message) + } catch (e) { + return res.status(500).send("Invalid JSON in message") + } + + if (hook.webhook) { + const webhookClient = new Discord.WebhookClient(hook.webhook, hook.secret) + await webhookClient.send(JSON.parse(message)) + res.sendStatus(204) + } else { + const channel = bot.channels.cache.get(hook.channel) + if (channel) { + await channel.send(JSON.parse(message)) + res.sendStatus(204) + } else res.status(500).send("Unable to send message of hook " + hook.id + " because the channel " + hook.channel + " does not exist") + } } -app.get("/hook/:id/:secret", hookFunc) + app.post("/hook/:id/:secret", hookFunc) +app.post("/hook/:id", async (req, res) => { + console.log(req.headers) + if (!req.get("X-Hub-Signature-256")) return res.status(401).send("Missing X-Hub-Signature-256 header") + + req.params.secret = req.get("X-Hub-Signature-256") + hookFunc(req, res) +}) diff --git a/util/oauth.js b/util/oauth.js index 3962a05..e63bbb9 100644 --- a/util/oauth.js +++ b/util/oauth.js @@ -19,35 +19,36 @@ module.exports.getUser = async token => { } const guildCache = {} -module.exports.getUserServers = async (userId, pool) => { - const token = await this.getAccessToken(userId, pool) - if (!token) return new Error("Couldnt get access token") +module.exports.getUserServers = async (token, pool) => { + const access = await this.getAccessToken(token, pool) + if (!access) return new Error("Couldnt get access token") - if (guildCache[token]) return guildCache[token] + if (guildCache[access]) return guildCache[access] const res = await fetch("https://discord.com/api/v10/users/@me/guilds", { headers: { "User-Agent": userAgent, - Authorization: "Bearer " + token, + Authorization: "Bearer " + access, Accept: "application/json" } }) if (!res.ok) return new Error("Couldnt get guild data, failed with " + res.status + " " + res.statusText) const json = await res.json() - guildCache[token] = json - setTimeout(() => delete guildCache[token], 1000 * 60 * 10) + guildCache[access] = json + setTimeout(() => delete guildCache[access], 1000 * 60 * 10) return json } -module.exports.getAccessToken = async (userId, pool) => { - const token = pool.query("SELECT * FROM `user` WHERE `id` = ?", [userId]) +module.exports.getAccessToken = async (token, pool) => { + const [rows] = await pool.query("SELECT * FROM `user` WHERE `token` = ?", [token]) + const access = rows[0] - if (token && Date.now() > token.expires_at) { + if (access && Date.now() > access.expires_at) { const body = new URLSearchParams({ client_id: botId, client_secret: botSecret, grant_type: "refresh_token", - refresh_token: token.refresh + refresh_token: access.refresh }) const res = await fetch("https://discord.com/api/v10/oauth2/token", { method: "POST", @@ -61,7 +62,7 @@ module.exports.getAccessToken = async (userId, pool) => { if (res.ok) { const newToken = await res.json() newToken.expires_at = Date.now() + newToken.expires_in * 1000 - newToken.type = token.type + newToken.type = access.type delete newToken.expires_in pool.query( @@ -76,7 +77,7 @@ module.exports.getAccessToken = async (userId, pool) => { } } - return token ? token.access : void 0 + return access ? access.access : void 0 } module.exports.generateToken = () => { diff --git a/util/setupDB.js b/util/setupDB.js index 3f1465a..4dacf8e 100644 --- a/util/setupDB.js +++ b/util/setupDB.js @@ -6,17 +6,19 @@ const mainDB = mysql.createPool(db) const pool = mainDB.promise() module.exports = pool -// hook schema: id secret guild webhook? channel? name? avatar? message +// hook schema: id secret guild webhook? channel? name? avatar? message filterEvent? filterAction? pool.query( "CREATE TABLE IF NOT EXISTS `hook` (" + "`id` VARCHAR(8) NOT NULL PRIMARY KEY," + "`secret` VARCHAR(64) NOT NULL," + "`guild` VARCHAR(21) NOT NULL," + - "`webhook` VARCHAR(128) NOT NULL," + - "`channel` VARCHAR(21) NOT NULL," + - "`name` VARCHAR(32) NOT NULL," + - "`avatar` VARCHAR(512) NOT NULL," + - "`message` VARCHAR(10240) NOT NULL" + + "`channel` VARCHAR(21) NULL DEFAULT NULL," + + "`webhook` VARCHAR(128) NULL DEFAULT NULL," + + "`name` VARCHAR(32) NULL DEFAULT NULL," + + "`avatar` VARCHAR(512) NULL DEFAULT NULL," + + "`message` VARCHAR(10240) NOT NULL," + + "`filterEvent` VARCHAR(512) NULL DEFAULT NULL," + + "`filterAction` VARCHAR(512) NULL DEFAULT NULL" + ")" )