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