Instance ban stuff

This commit is contained in:
Rory&
2025-12-13 02:01:32 +01:00
parent 8a785da5e4
commit dae89fd0af
8 changed files with 109 additions and 57 deletions
+10 -7
View File
@@ -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>
+1 -1
View File
@@ -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)}`);
+15 -1
View File
@@ -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;
+2
View File
@@ -51,6 +51,8 @@ router.post(
try {
const userTokenData = await checkToken(token, {
select: ["email"],
fingerprint: req.fingerprint,
ipAddress: req.ip
});
user = userTokenData.user;
} catch {
+4 -1
View File
@@ -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({
@@ -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
+55 -1
View File
@@ -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";
@@ -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<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;
}
}
+21 -45
View File
@@ -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 });
}