diff --git a/.idea/workspace.xml b/.idea/workspace.xml index c52fdde44..dfbf2c4a5 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 @@ }]]> - - - - + + + + @@ -161,7 +162,9 @@ - + + + diff --git a/scripts/util/getRouteDescriptions.js b/scripts/util/getRouteDescriptions.js index 9f329f323..0372aae7e 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 4bb809a67..5d239a17e 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 b14eb3791..5cb5690d3 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 d8e343cd2..8c636196e 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 ae1c6cb87..9fbf5d218 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 6f92e2ecc..08100a9fb 100644 --- a/src/util/entities/InstanceBan.ts +++ b/src/util/entities/InstanceBan.ts @@ -16,7 +16,7 @@ along with this program. If not, see . */ -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"; @@ -44,6 +44,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; @@ -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[] = [{ 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[] = []; + 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 576349538..db2a313b9 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; relations?: FindOptionsRelationByString; + ipAddress?: string; + fingerprint?: string; }, ): Promise => 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 }); }