From 336b2df1d7ccad9f7d122febc86eb3b6d91bfbc5 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Mar 2025 16:28:25 -0700 Subject: [PATCH] Fixed errors in authorization for webhooks with tokens --- assets/openapi.json | Bin 628420 -> 637104 bytes assets/schemas.json | Bin 25370417 -> 25537875 bytes src/api/middlewares/Authentication.ts | 2 +- .../routes/channels/#channel_id/webhooks.ts | 36 +++- .../webhooks/#webhook_id/#token/index.ts | 139 +++++++++++++- src/api/routes/webhooks/#webhook_id/index.ts | 172 ++++++++++++++++++ src/util/schemas/WebhookUpdateSchema.ts | 23 +++ src/util/schemas/index.ts | 1 + 8 files changed, 365 insertions(+), 8 deletions(-) mode change 100644 => 100755 assets/schemas.json create mode 100755 src/util/schemas/WebhookUpdateSchema.ts diff --git a/assets/openapi.json b/assets/openapi.json index edd49baa131f578e833f3a086fe214ffbef032ba..df2e7d9092389108b8b77bd3c58d1c56ea0a50d7 100644 GIT binary patch delta 482 zcmZvWJxE(&6o&cEcfQ=4#i(E+4l&dpuv)#H3Sw)dgFgsnaOk3@Ai2@WAws3lW>A8e zE=GJokP6Z+tu%u8btrO0X$6G{T}l_TRCH38Duk9^GdPIDGrWg~_q==S&H2mb^sLyV z$ppTcR0sEw3d`wK5~VoV2v~jvouDlD+}wI5D1qY=JwW6{gzWMXr+~8|!#dk0hXlIY zWdZL7WFOpssKs%ev~&4s#@{p1ji@D?P$by_=OG(-`wT z{o(`fhTx{mR(&*Tu9N4O`+DUozY&No8I}5IL}f&7$T9G8OsygIgRgIr&OS5e2vak@ zUTy90C$aQLJx6?xTd;b{K^%PHpzht~Csp^AR|~i^@-ZCvOkK0fv*Nx#sJt=_O#PD8 z$iG(s#0QKJ^5@im)rjiQ)^BzMVnL%Fk#S$&?ee?jDN&x3SuC)J^6+^Wda+j&gdzo@_>F^OMd`decr_UZ4Xn6;)~_{?lN zy+MeXeftG-#)VAN4N6(`xBIMSIV?W?!aFvx?dca-d=Xrq_Imb)9%z0kn zRW9%vuk!|P@)mD%k#~5P_jsQV_>hnIm{0hW&-k1#_>xO}#n*hpw|vLOU2)U{pDOZ!L%TaO-xu#r8j+Sf7b>zBo zJ-NQzKyD~Ek{ioSo5n8@a9APHr!EkUPqqBjl0tD0#Fz zMoyE{<*{;xoGFi!$IBDsiSi^lOP(xe%Twg3@-%t6oFmVWXUenWTzR&fC(n`R>SVso zCWbB_Xj6OlN~IiM(Di_VlB->Q9i{{!@b$9@0+ delta 986 zcmWl}b({!x0LJ0Zt4^G54yQX#$LTumPIt#)Om{nc*^QT*ZQ26sb`zUAwP1Cd+Kf$X ztV3J!d;WM%o?92Ws+~grI`N&TJ3Q~x|6rm`^C{77V z5=ALWQ-)~DQjYRepdyv1Ockn9jq22(Cbg(d9b%|UJ?hhdhBTrvO=wCpVrfnbTGEQv zw4p8SXio<^5=SRG6Hfw(bfGKV=uQuM(u>~op)dXD&j1E8h`|hDD8m@e2u6~`C`L1e zhZxH^9%eifn8+k1Gli*4V>&aK$t-3whq=sSJ_}gLB9d9m5|#qXSk5D)@F*)-iCM*J zQhAIuJkAq5$y(O2o(*hd6Pww>Q#{QxJj+(Lv7P65o)>tLmw1_1c$L?9oi})sx7a}% zZ?ltK>}C(|@Gg7V$9@iQkaP}lm?IqJ81L~uAMhc^Il)JK%qM)xXJqg>UvQFBoaPK? z`I4_V$JdoaI&-}u#{KoJ6!Jk~`1~<9IZSHWFd;G=U z{KLQe$Nwp^_a(=mhTu?3~7nY02Mdf00 zak+$CQjU^K$))8oa&f-y z2698Wk=$5rA~%(r$+2>CxrN+PZY8&t+sJL@c5-{UgWOS$lRL?s<#;(kPL#XIUFB|a zce#h$Q|=}Amix$k<$iL1d4N1n9wZNzhsZp>Gj6GM|ilS5NNQ$y22(?c^tGefgNvqN)2b3^k&^Fs?l Y3qy-S$)UxeC84Dul%5v1Ok0!g0}*qT0RR91 diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts index b4f48477..f0f01252 100644 --- a/src/api/middlewares/Authentication.ts +++ b/src/api/middlewares/Authentication.ts @@ -32,7 +32,7 @@ export const NO_AUTHORIZATION_ROUTES = [ "POST /auth/reset", "GET /invites/", // Routes with a seperate auth system - /^(POST|HEAD) \/webhooks\/\d+\/\w+\/?/, // no token requires auth + /^(POST|HEAD|GET|PATCH|DELETE) \/webhooks\/\d+\/\w+\/?/, // no token requires auth // Public information endpoints "GET /ping", "GET /gateway", diff --git a/src/api/routes/channels/#channel_id/webhooks.ts b/src/api/routes/channels/#channel_id/webhooks.ts index 2060760d..152f6d00 100644 --- a/src/api/routes/channels/#channel_id/webhooks.ts +++ b/src/api/routes/channels/#channel_id/webhooks.ts @@ -28,6 +28,7 @@ import { handleFile, isTextChannel, trimSpecial, + FieldErrors, } from "@spacebar/util"; import crypto from "crypto"; import { Request, Response, Router } from "express"; @@ -111,8 +112,39 @@ router.post( name = trimSpecial(name); // TODO: move this - if (name === "clyde") throw new HTTPError("Invalid name", 400); - if (name === "Spacebar Ghost") throw new HTTPError("Invalid name", 400); + if (name) { + const check_username = name.replace(/\s/g, ""); + if (!check_username) { + throw FieldErrors({ + username: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, + }); + } + + const { maxUsername } = Config.get().limits.user; + if ( + check_username.length > maxUsername || + check_username.length < 2 + ) { + throw FieldErrors({ + username: { + code: "BASE_TYPE_BAD_LENGTH", + message: `Must be between 2 and ${maxUsername} in length.`, + }, + }); + } + + const blockedContains = ["discord", "clyde", "spacebar"]; + for (const word of blockedContains) { + if (name.toLowerCase().includes(word)) { + return res.status(400).json({ + username: [`Username cannot contain "${word}"`], + }); + } + } + } if (avatar) avatar = await handleFile(`/avatars/${channel_id}`, avatar); diff --git a/src/api/routes/webhooks/#webhook_id/#token/index.ts b/src/api/routes/webhooks/#webhook_id/#token/index.ts index 8e0ad0dd..aa73b3fe 100644 --- a/src/api/routes/webhooks/#webhook_id/#token/index.ts +++ b/src/api/routes/webhooks/#webhook_id/#token/index.ts @@ -10,6 +10,9 @@ import { WebhookExecuteSchema, emitEvent, uploadFile, + WebhooksUpdateEvent, + WebhookUpdateSchema, + handleFile, } from "@spacebar/util"; import { Request, Response, Router } from "express"; import { HTTPError } from "lambert-server"; @@ -129,13 +132,38 @@ router.post( // block username from containing certain words // TODO: configurable additions - const blockedContains = ["discord", "clyde", "spacebar"]; - for (const word of blockedContains) { - if (body.username?.toLowerCase().includes(word)) { - return res.status(400).json({ - username: [`Username cannot contain "${word}"`], + if (body.username) { + const check_username = body.username.replace(/\s/g, ""); + if (!check_username) { + throw FieldErrors({ + username: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, }); } + + const { maxUsername } = Config.get().limits.user; + if ( + check_username.length > maxUsername || + check_username.length < 2 + ) { + throw FieldErrors({ + username: { + code: "BASE_TYPE_BAD_LENGTH", + message: `Must be between 2 and ${maxUsername} in length.`, + }, + }); + } + + const blockedContains = ["discord", "clyde", "spacebar"]; + for (const word of blockedContains) { + if (body.username.toLowerCase().includes(word)) { + return res.status(400).json({ + username: [`Username cannot contain "${word}"`], + }); + } + } } // block username from being certain words @@ -248,4 +276,105 @@ router.post( }, ); +router.delete( + "/", + route({ + responses: { + 204: {}, + 400: { + body: "APIErrorResponse", + }, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id, token } = req.params; + + const webhook = await Webhook.findOne({ + where: { + id: webhook_id, + }, + relations: ["channel", "guild", "application"], + }); + + if (!webhook) { + throw DiscordApiErrors.UNKNOWN_WEBHOOK; + } + + if (webhook.token !== token) { + throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED; + } + const channel_id = webhook.channel_id; + await Webhook.delete({ id: webhook_id }); + + await emitEvent({ + event: "WEBHOOKS_UPDATE", + channel_id, + data: { + channel_id, + guild_id: webhook.guild_id, + }, + } as WebhooksUpdateEvent); + + res.sendStatus(204); + }, +); + +router.patch( + "/", + route({ + requestBody: "WebhookUpdateSchema", + responses: { + 200: { + body: "Message", + }, + 400: { + body: "APIErrorResponse", + }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id, token } = req.params; + const body = req.body as WebhookUpdateSchema; + + const webhook = await Webhook.findOneOrFail({ + where: { id: webhook_id }, + relations: [ + "user", + "channel", + "source_channel", + "guild", + "source_guild", + "application", + ], + }); + + const channel_id = webhook.channel_id; + if (!body.name && !body.avatar) { + throw new HTTPError("Empty messages are not allowed", 50006); + } + if (body.avatar) + body.avatar = await handleFile( + `/avatars/${webhook_id}`, + body.avatar as string, + ); + webhook.assign(body); + + await Promise.all([ + webhook.save(), + emitEvent({ + event: "WEBHOOKS_UPDATE", + channel_id, + data: { + channel_id, + guild_id: webhook.guild_id, + }, + } as WebhooksUpdateEvent), + ]); + res.status(204); + }, +); + export default router; diff --git a/src/api/routes/webhooks/#webhook_id/index.ts b/src/api/routes/webhooks/#webhook_id/index.ts index 59fdb76d..c27b4612 100644 --- a/src/api/routes/webhooks/#webhook_id/index.ts +++ b/src/api/routes/webhooks/#webhook_id/index.ts @@ -4,8 +4,15 @@ import { DiscordApiErrors, getPermission, Webhook, + WebhooksUpdateEvent, + emitEvent, + WebhookUpdateSchema, + Channel, + handleFile, + FieldErrors, } from "@spacebar/util"; import { Request, Response, Router } from "express"; +import { HTTPError } from "lambert-server"; const router = Router(); router.get( @@ -54,4 +61,169 @@ router.get( }, ); +router.delete( + "/", + route({ + responses: { + 204: {}, + 400: { + body: "APIErrorResponse", + }, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id } = req.params; + + const webhook = await Webhook.findOneOrFail({ + where: { id: webhook_id }, + relations: [ + "user", + "channel", + "source_channel", + "guild", + "source_guild", + "application", + ], + }); + + if (webhook.guild_id) { + const permission = await getPermission( + req.user_id, + webhook.guild_id, + ); + + if (!permission.has("MANAGE_WEBHOOKS")) + throw DiscordApiErrors.UNKNOWN_WEBHOOK; + } else if (webhook.user_id != req.user_id) + throw DiscordApiErrors.UNKNOWN_WEBHOOK; + + const channel_id = webhook.channel_id; + await Webhook.delete({ id: webhook_id }); + + await emitEvent({ + event: "WEBHOOKS_UPDATE", + channel_id, + data: { + channel_id, + guild_id: webhook.guild_id, + }, + } as WebhooksUpdateEvent); + + res.sendStatus(204); + }, +); + +router.patch( + "/", + route({ + requestBody: "WebhookUpdateSchema", + responses: { + 200: { + body: "WebhookCreateResponse", + }, + 400: { + body: "APIErrorResponse", + }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id } = req.params; + const body = req.body as WebhookUpdateSchema; + + const webhook = await Webhook.findOneOrFail({ + where: { id: webhook_id }, + relations: [ + "user", + "channel", + "source_channel", + "guild", + "source_guild", + "application", + ], + }); + + if (webhook.guild_id) { + const permission = await getPermission( + req.user_id, + webhook.guild_id, + ); + + if (!permission.has("MANAGE_WEBHOOKS")) + throw DiscordApiErrors.UNKNOWN_WEBHOOK; + } else if (webhook.user_id != req.user_id) + throw DiscordApiErrors.UNKNOWN_WEBHOOK; + + if (!body.name && !body.avatar && !body.channel_id) { + throw new HTTPError("Empty messages are not allowed", 50006); + } + + if (body.avatar) + body.avatar = await handleFile( + `/avatars/${webhook_id}`, + body.avatar as string, + ); + + if (body.name) { + const check_username = body.name.replace(/\s/g, ""); + if (!check_username) { + throw FieldErrors({ + username: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, + }); + } + + const { maxUsername } = Config.get().limits.user; + if ( + check_username.length > maxUsername || + check_username.length < 2 + ) { + throw FieldErrors({ + username: { + code: "BASE_TYPE_BAD_LENGTH", + message: `Must be between 2 and ${maxUsername} in length.`, + }, + }); + } + + const blockedContains = ["discord", "clyde", "spacebar"]; + for (const word of blockedContains) { + if (body.name.toLowerCase().includes(word)) { + return res.status(400).json({ + username: [`Username cannot contain "${word}"`], + }); + } + } + } + + const channel_id = body.channel_id || webhook.channel_id; + webhook.assign(body); + + if (body.channel_id) + webhook.assign({ + channel: await Channel.findOneOrFail({ + where: { id: channel_id }, + }), + }); + console.log(webhook.channel_id); + + await webhook.save(); + await emitEvent({ + event: "WEBHOOKS_UPDATE", + channel_id, + data: { + channel_id, + guild_id: webhook.guild_id, + }, + } as WebhooksUpdateEvent); + + console.log(webhook.channel_id); + res.json(webhook); + }, +); + export default router; diff --git a/src/util/schemas/WebhookUpdateSchema.ts b/src/util/schemas/WebhookUpdateSchema.ts new file mode 100755 index 00000000..bc276e44 --- /dev/null +++ b/src/util/schemas/WebhookUpdateSchema.ts @@ -0,0 +1,23 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +export interface WebhookUpdateSchema { + name?: string; + avatar?: string; + channel_id?: string; +} diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts index 2972b1a5..ca473dce 100644 --- a/src/util/schemas/index.ts +++ b/src/util/schemas/index.ts @@ -86,4 +86,5 @@ export * from "./VoiceVideoSchema"; export * from "./WebAuthnSchema"; export * from "./WebhookCreateSchema"; export * from "./WebhookExecuteSchema"; +export * from "./WebhookUpdateSchema"; export * from "./WidgetModifySchema";