summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2025-12-13 02:01:32 +0100
committerRory& <root@rory.gay>2025-12-13 02:01:32 +0100
commitdae89fd0afe54a9ab05e2f5484a40ae9101b1a82 (patch)
tree98365829d6ae2140611fa696bc9d539407febad4
parentFix CI tests (diff)
downloadserver-ts-dae89fd0afe54a9ab05e2f5484a40ae9101b1a82.tar.xz
Instance ban stuff
-rw-r--r--.idea/workspace.xml17
-rw-r--r--scripts/util/getRouteDescriptions.js2
-rw-r--r--src/api/middlewares/Authentication.ts16
-rw-r--r--src/api/routes/auth/reset.ts2
-rw-r--r--src/api/routes/auth/verify/index.ts5
-rw-r--r--src/util/config/types/SecurityConfiguration.ts2
-rw-r--r--src/util/entities/InstanceBan.ts56
-rw-r--r--src/util/util/Token.ts66
8 files changed, 109 insertions, 57 deletions
diff --git a/.idea/workspace.xml b/.idea/workspace.xml

index c52fdde4..dfbf2c4a 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml
@@ -55,9 +55,10 @@ "Node.js.Server.ts.executor": "Debug", "RunOnceActivity.ShowReadmeOnStart": "true", "RunOnceActivity.git.unshallow": "true", + "git-widget-placeholder": "master", "javascript.nodejs.core.library.configured.version": "24.8.0", "javascript.nodejs.core.library.typings.version": "24.7.0", - "last_opened_file_path": "/home/Rory/git/spacebar/server-master/src/util/migration/postgres", + "last_opened_file_path": "/home/Rory/git/spacebar/server-master/src/schemas/api/users", "node.js.detected.package.eslint": "true", "node.js.detected.package.standard": "true", "node.js.selected.package.eslint": "(autodetect)", @@ -71,7 +72,7 @@ "npm.build.executor": "Run", "npm.start.executor": "Debug", "prettierjs.PrettierConfiguration.Package": "/home/Rory/git/spacebar/server-master/node_modules/prettier", - "settings.editor.selected.configurable": "preferences.sourceCode", + "settings.editor.selected.configurable": "com.github.copilot.settings.customInstructions.CopilotInstructionsConfigurable", "ts.external.directory.path": "/home/Rory/git/spacebar/server-master/node_modules/typescript/lib" }, "keyToStringList": { @@ -87,11 +88,11 @@ }]]></component> <component name="RecentsManager"> <key name="CopyFile.RECENT_KEYS"> - <recent name="$PROJECT_DIR$/src/util/migration/postgres" /> - <recent name="$PROJECT_DIR$/src/util/entities" /> - <recent name="$PROJECT_DIR$/src/schemas/api/guilds" /> - <recent name="$PROJECT_DIR$/src/schemas" /> <recent name="$PROJECT_DIR$/src/schemas/api/users" /> + <recent name="$PROJECT_DIR$/src/util/entities" /> + <recent name="$PROJECT_DIR$/src/gateway/opcodes" /> + <recent name="$PROJECT_DIR$/src/api/routes/users/@me/billing" /> + <recent name="$PROJECT_DIR$/src/util/util/networking/abuseipdb" /> </key> <key name="MoveFile.RECENT_KEYS"> <recent name="$PROJECT_DIR$/src/schemas/api/guilds" /> @@ -161,7 +162,9 @@ <workItem from="1760044946282" duration="43683000" /> <workItem from="1760402350251" duration="49898000" /> <workItem from="1760538864442" duration="1330000" /> - <workItem from="1764432507485" duration="22274000" /> + <workItem from="1764432507485" duration="30779000" /> + <workItem from="1764521899659" duration="22029000" /> + <workItem from="1765142585081" duration="16912000" /> </task> <servers /> </component> diff --git a/scripts/util/getRouteDescriptions.js b/scripts/util/getRouteDescriptions.js
index 9f329f32..0372aae7 100644 --- a/scripts/util/getRouteDescriptions.js +++ b/scripts/util/getRouteDescriptions.js
@@ -49,7 +49,7 @@ function proxy(file, apiMethod, apiPathPrefix, apiPath, ...args) { const opts = args.find((x) => x?.prototype?.OPTS_MARKER == true); if (!opts) return console.error( - ` \x1b[5m${bgRedBright("ERROR")}\x1b[25m ${file.replace(path.resolve(__dirname, "..", "..", "dist"), "/src/")} has route without route() description middleware: ${colorizeMethod(apiMethod)} ${formatPath(apiPath)}`, + ` \x1b[5m${bgRedBright("ERROR")}\x1b[25m ${file.replace(path.resolve(__dirname, "..", "..", "dist"), "/src")} has route without route() description middleware: ${colorizeMethod(apiMethod)} ${formatPath(apiPath)}`, ); console.log(`${colorizeMethod(apiMethod).padStart("DELETE".length + 10)} ${formatPath(apiPathPrefix + apiPath)}`); diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts
index 4bb809a6..5d239a17 100644 --- a/src/api/middlewares/Authentication.ts +++ b/src/api/middlewares/Authentication.ts
@@ -70,6 +70,7 @@ declare global { user_bot: boolean; token: { id: string; iat: number }; rights: Rights; + fingerprint?: string; } } } @@ -77,6 +78,15 @@ declare global { export async function Authentication(req: Request, res: Response, next: NextFunction) { if (req.method === "OPTIONS") return res.sendStatus(204); const url = req.url.replace(API_PREFIX, ""); + + if (req.headers.cookie?.split("; ").find((x) => x.startsWith("__sb_sessid="))) + req.fingerprint = req.headers.cookie + .split("; ") + .find((x) => x.startsWith("__sb_sessid="))! + .split("=")[1]; + // for some reason we need to require here, else the openapi generator fails with "route is not a function" + else res.setHeader("Set-Cookie", `__sb_sessid=${req.fingerprint = (await require("../util")).randomString(32)}; Secure; HttpOnly; SameSite=None`); + if ( NO_AUTHORIZATION_ROUTES.some((x) => { if (typeof x !== "string") { @@ -102,10 +112,14 @@ export async function Authentication(req: Request, res: Response, next: NextFunc }) ) return next(); + if (!req.headers.authorization) return next(new HTTPError("Missing Authorization Header", 401)); try { - const { decoded, user } = await checkToken(req.headers.authorization); + const { decoded, user } = await checkToken(req.headers.authorization, { + ipAddress: req.ip, + fingerprint: req.fingerprint, + }); req.token = decoded; req.user_id = decoded.id; diff --git a/src/api/routes/auth/reset.ts b/src/api/routes/auth/reset.ts
index b14eb379..5cb5690d 100644 --- a/src/api/routes/auth/reset.ts +++ b/src/api/routes/auth/reset.ts
@@ -51,6 +51,8 @@ router.post( try { const userTokenData = await checkToken(token, { select: ["email"], + fingerprint: req.fingerprint, + ipAddress: req.ip }); user = userTokenData.user; } catch { diff --git a/src/api/routes/auth/verify/index.ts b/src/api/routes/auth/verify/index.ts
index d8e343cd..8c636196 100644 --- a/src/api/routes/auth/verify/index.ts +++ b/src/api/routes/auth/verify/index.ts
@@ -75,7 +75,10 @@ router.post( let user; try { - const userTokenData = await checkToken(token); + const userTokenData = await checkToken(token, { + fingerprint: req.fingerprint, + ipAddress: req.ip + }); user = userTokenData.user; } catch { throw FieldErrors({ diff --git a/src/util/config/types/SecurityConfiguration.ts b/src/util/config/types/SecurityConfiguration.ts
index ae1c6cb8..9fbf5d21 100644 --- a/src/util/config/types/SecurityConfiguration.ts +++ b/src/util/config/types/SecurityConfiguration.ts
@@ -24,7 +24,7 @@ export class SecurityConfiguration { twoFactor: TwoFactorConfiguration = new TwoFactorConfiguration(); autoUpdate: boolean | number = true; requestSignature: string = crypto.randomBytes(32).toString("base64"); - jwtSecret: string = crypto.randomBytes(256).toString("base64"); + jwtSecret: string = crypto.randomBytes(256).toString("base64"); // deprecated // header to get the real user ip address // X-Forwarded-For for nginx/reverse proxies // CF-Connecting-IP for cloudflare diff --git a/src/util/entities/InstanceBan.ts b/src/util/entities/InstanceBan.ts
index 6f92e2ec..08100a9f 100644 --- a/src/util/entities/InstanceBan.ts +++ b/src/util/entities/InstanceBan.ts
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, OneToOne, RelationId } from "typeorm"; +import { Column, CreateDateColumn, Entity, FindOptionsWhere, JoinColumn, ManyToOne, OneToOne, RelationId } from "typeorm"; import { BaseClass } from "./BaseClass"; import { Team } from "./Team"; import { User } from "./User"; @@ -45,6 +45,9 @@ export class InstanceBan extends BaseClass { // chain of trust type tracking @Column({ default: false }) + is_allowlisted: boolean = false; + + @Column({ default: false }) is_from_other_instance_ban: boolean = false; @Column({ nullable: true }) @@ -54,4 +57,55 @@ export class InstanceBan extends BaseClass { @JoinColumn({ name: "origin_instance_ban_id" }) @OneToOne(() => InstanceBan, { nullable: true, onDelete: "SET NULL" }) origin_instance_ban?: InstanceBan; + + static async findInstanceBans(opts: { userId?: string; ipAddress?: string; fingerprint?: string; propagateBan?: boolean }) { + const optionalChecks: FindOptionsWhere<InstanceBan>[] = [{ user_id: opts.userId }]; + if (opts?.ipAddress) optionalChecks.push({ ip_address: opts.ipAddress }); + if (opts?.fingerprint) optionalChecks.push({ fingerprint: opts.fingerprint }); + const instanceBans = await InstanceBan.find({ where: optionalChecks }); + + const banReasons = []; + for (const ban of instanceBans) { + if (ban.is_allowlisted) continue; + if (opts?.fingerprint && ban.fingerprint === opts.fingerprint) banReasons.push("fingerprint"); + if (opts?.ipAddress && ban.ip_address === opts.ipAddress) banReasons.push("ipAddress"); + if (opts?.userId && ban.user_id === opts?.userId) banReasons.push("userId"); + } + + const banViralityPromises: Promise<InstanceBan>[] = []; + if (opts.propagateBan && banReasons.length > 0) { + if (opts?.ipAddress && !instanceBans.find((b) => b.ip_address === opts.ipAddress)) + banViralityPromises.push( + InstanceBan.create({ + user_id: opts.userId, + ip_address: opts.ipAddress, + reason: "Propagated from other instance ban", + is_from_other_instance_ban: true, + origin_instance_ban: instanceBans[0], + }).save(), + ); + if (opts?.fingerprint && !instanceBans.find((b) => b.fingerprint === opts.fingerprint)) + banViralityPromises.push( + InstanceBan.create({ + user_id: opts.userId, + fingerprint: opts.fingerprint, + reason: "Propagated from other instance ban", + is_from_other_instance_ban: true, + origin_instance_ban: instanceBans[0], + }).save(), + ); + if (opts?.userId && !instanceBans.find((b) => b.user_id === opts.userId)) + banViralityPromises.push( + InstanceBan.create({ + user_id: opts.userId, + reason: "Propagated from other instance ban", + is_from_other_instance_ban: true, + origin_instance_ban: instanceBans[0], + }).save(), + ); + } + + await Promise.all(banViralityPromises); + return banReasons; + } } diff --git a/src/util/util/Token.ts b/src/util/util/Token.ts
index 57634953..db2a313b 100644 --- a/src/util/util/Token.ts +++ b/src/util/util/Token.ts
@@ -18,15 +18,12 @@ import jwt, { VerifyOptions } from "jsonwebtoken"; import { Config } from "./Config"; -import { User } from "../entities"; +import { InstanceBan, User } from "../entities"; import crypto from "node:crypto"; import fs from "fs/promises"; import { existsSync } from "fs"; // TODO: dont use deprecated APIs lol -import { - FindOptionsRelationByString, - FindOptionsSelectByString, -} from "typeorm"; +import { FindManyOptions, FindOptions, FindOptionsRelationByString, FindOptionsSelect, FindOptionsSelectByString, FindOptionsWhere } from "typeorm"; import * as console from "node:console"; export const JWTOptions: VerifyOptions = { algorithms: ["HS256"] }; @@ -37,7 +34,7 @@ export type UserTokenData = { }; function logAuth(text: string) { - if(process.env.LOG_AUTH !== "true") return; + if (process.env.LOG_AUTH !== "true") return; console.log(`[AUTH] ${text}`); } @@ -51,6 +48,8 @@ export const checkToken = ( opts?: { select?: FindOptionsSelectByString<User>; relations?: FindOptionsRelationByString; + ipAddress?: string; + fingerprint?: string; }, ): Promise<UserTokenData> => new Promise((resolve, reject) => { @@ -66,15 +65,7 @@ export const checkToken = ( const user = await User.findOne({ where: { id: decoded.id }, - select: [ - ...(opts?.select || []), - "id", - "bot", - "disabled", - "deleted", - "rights", - "data", - ], + select: [...(opts?.select || []), "id", "bot", "disabled", "deleted", "rights", "data"], relations: opts?.relations, }); @@ -84,10 +75,7 @@ export const checkToken = ( } // we need to round it to seconds as it saved as seconds in jwt iat and valid_tokens_since is stored in milliseconds - if ( - decoded.iat * 1000 < - new Date(user.data.valid_tokens_since).setSeconds(0, 0) - ) { + if (decoded.iat * 1000 < new Date(user.data.valid_tokens_since).setSeconds(0, 0)) { logAuth("validateUser rejected: Token not yet valid"); return rejectAndLog(reject, "Invalid Token"); } @@ -96,11 +84,18 @@ export const checkToken = ( logAuth("validateUser rejected: User disabled"); return rejectAndLog(reject, "User disabled"); } + if (user.deleted) { logAuth("validateUser rejected: User deleted"); return rejectAndLog(reject, "User not found"); } + const banReasons = await InstanceBan.findInstanceBans({ userId: user.id, ipAddress: opts?.ipAddress, fingerprint: opts?.fingerprint, propagateBan: true }); + if (banReasons.length > 0) { + logAuth("validateUser rejected: User banned for reasons: " + banReasons.join(", ")); + return rejectAndLog(reject, "Invalid Token"); + } + logAuth("validateUser success: " + JSON.stringify({ decoded, user })); return resolve({ decoded, user }); }; @@ -110,20 +105,10 @@ export const checkToken = ( logAuth("Decoded token: " + JSON.stringify(dec)); if (dec.header.alg == "HS256") { - jwt.verify( - token, - Config.get().security.jwtSecret, - JWTOptions, - validateUser, - ); + jwt.verify(token, Config.get().security.jwtSecret, JWTOptions, validateUser); } else if (dec.header.alg == "ES512") { loadOrGenerateKeypair().then((keyPair) => { - jwt.verify( - token, - keyPair.publicKey, - { algorithms: ["ES512"] }, - validateUser, - ); + jwt.verify(token, keyPair.publicKey, { algorithms: ["ES512"] }, validateUser); }); } else return reject("Invalid token algorithm"); }); @@ -152,7 +137,7 @@ let cachedKeypair: { privateKey: crypto.KeyObject; publicKey: crypto.KeyObject; fingerprint: string; -} +}; // Get ECDSA keypair from file or generate it export async function loadOrGenerateKeypair() { @@ -176,10 +161,7 @@ export async function loadOrGenerateKeypair() { let publicKey: crypto.KeyObject; if (existsSync("jwt.key") && existsSync("jwt.key.pub")) { - const [loadedPrivateKey, loadedPublicKey] = await Promise.all([ - fs.readFile("jwt.key"), - fs.readFile("jwt.key.pub"), - ]); + const [loadedPrivateKey, loadedPublicKey] = await Promise.all([fs.readFile("jwt.key"), fs.readFile("jwt.key.pub")]); privateKey = crypto.createPrivateKey(loadedPrivateKey); publicKey = crypto.createPublicKey(loadedPublicKey); @@ -192,14 +174,8 @@ export async function loadOrGenerateKeypair() { publicKey = res.publicKey; await Promise.all([ - fs.writeFile( - "jwt.key", - privateKey.export({ format: "pem", type: "sec1" }), - ), - fs.writeFile( - "jwt.key.pub", - publicKey.export({ format: "pem", type: "spki" }), - ), + fs.writeFile("jwt.key", privateKey.export({ format: "pem", type: "sec1" })), + fs.writeFile("jwt.key.pub", publicKey.export({ format: "pem", type: "spki" })), ]); } @@ -209,5 +185,5 @@ export async function loadOrGenerateKeypair() { .digest("hex"); lastFsCheck = Date.now(); - return cachedKeypair = { privateKey, publicKey, fingerprint }; + return (cachedKeypair = { privateKey, publicKey, fingerprint }); }