Fixed errors in authorization for webhooks with tokens

This commit is contained in:
root 2025-03-23 16:28:25 -07:00
parent fdab1c9945
commit 336b2df1d7
8 changed files with 365 additions and 8 deletions

Binary file not shown.

BIN
assets/schemas.json Normal file → Executable file

Binary file not shown.

View File

@ -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",

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
export interface WebhookUpdateSchema {
name?: string;
avatar?: string;
channel_id?: string;
}

View File

@ -86,4 +86,5 @@ export * from "./VoiceVideoSchema";
export * from "./WebAuthnSchema";
export * from "./WebhookCreateSchema";
export * from "./WebhookExecuteSchema";
export * from "./WebhookUpdateSchema";
export * from "./WidgetModifySchema";