From c3c8026041d29d7b50d54080d21518cadae97fff Mon Sep 17 00:00:00 2001 From: Flam3rboy <34555296+Flam3rboy@users.noreply.github.com> Date: Thu, 1 Jul 2021 21:27:46 +0200 Subject: [PATCH] :sparkles: route specific rate limits --- package-lock.json | 66 +++++++++++++++++++++++------------- package.json | 2 +- src/Server.ts | 9 ++--- src/middlewares/RateLimit.ts | 36 ++++++++++++-------- src/routes/auth/login.ts | 2 ++ src/routes/auth/register.ts | 2 ++ 6 files changed, 74 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index 728f252d..56a7449e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@fosscord/server-util": "^1.3.23", + "@fosscord/server-util": "^1.3.24", "@types/jest": "^26.0.22", "@types/json-schema": "^7.0.7", "ajv": "^8.4.0", @@ -550,9 +550,9 @@ } }, "node_modules/@fosscord/server-util": { - "version": "1.3.23", - "resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.23.tgz", - "integrity": "sha512-YxkuMwsJmMpCN4zGCq0LHvUuV9zlR8yTriquPqWfp5Sbj1DdFz7Qqo6wz6cRYb3WRIINouHhV60cbljmUqLIJQ==", + "version": "1.3.24", + "resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.24.tgz", + "integrity": "sha512-5r/OkFalmQ7xQ6ZU1ujNvShlokKVcDXsc+S7oLYyhrEWW5Nl1bqHEHDVqWmYt5OX+9449QJNob7DZK1T1r1a2Q==", "dependencies": { "@types/jsonwebtoken": "^8.5.0", "@types/mongoose-autopopulate": "^0.10.1", @@ -1124,19 +1124,19 @@ } }, "node_modules/@types/mongoose-autopopulate": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@types/mongoose-autopopulate/-/mongoose-autopopulate-0.10.1.tgz", - "integrity": "sha512-L67MAIE3WEoTtt7a7/spRYk+76lgp67FAP6I38Y9NcC1kQuzwqnukTaJzodfb8180wxHZM4qt68u6x6ptuDRaQ==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@types/mongoose-autopopulate/-/mongoose-autopopulate-0.10.2.tgz", + "integrity": "sha512-YSxSEhszXK9E+7VRLdpYjkXqcRXOPFtG0xZea9n7A+oaHhZ1lSVBm/WvK2Rr746NPrTm/k1tR6uezyG6kyinyg==", "dependencies": { - "@types/mongoose": "*" + "@types/mongoose": "5.10.5" } }, "node_modules/@types/mongoose-lean-virtuals": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@types/mongoose-lean-virtuals/-/mongoose-lean-virtuals-0.5.1.tgz", - "integrity": "sha512-bNk+QLjP5VZU4EsJag4xQsjLAa8CEm/SKZDyiC2kM208wIrGum6daD7j45Oqs50bWNGfqZYRuEhh8xZ17D7aEw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@types/mongoose-lean-virtuals/-/mongoose-lean-virtuals-0.5.2.tgz", + "integrity": "sha512-TpAX2RkFXLtNjyciiYxdvYpVuCAv/g1alFTl4ErJWvSOA+DuNDNvfXSH3c8/DXC1ZBzO47TCwHaxI/PET4sqxQ==", "dependencies": { - "@types/mongoose": "*" + "@types/mongoose": "5.10.5" } }, "node_modules/@types/multer": { @@ -6413,6 +6413,26 @@ }, "optionalDependencies": { "saslprep": "^1.0.0" + }, + "peerDependenciesMeta": { + "aws4": { + "optional": true + }, + "bson-ext": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "mongodb-extjson": { + "optional": true + }, + "snappy": { + "optional": true + } } }, "node_modules/mongoose": { @@ -10653,9 +10673,9 @@ } }, "@fosscord/server-util": { - "version": "1.3.23", - "resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.23.tgz", - "integrity": "sha512-YxkuMwsJmMpCN4zGCq0LHvUuV9zlR8yTriquPqWfp5Sbj1DdFz7Qqo6wz6cRYb3WRIINouHhV60cbljmUqLIJQ==", + "version": "1.3.24", + "resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.24.tgz", + "integrity": "sha512-5r/OkFalmQ7xQ6ZU1ujNvShlokKVcDXsc+S7oLYyhrEWW5Nl1bqHEHDVqWmYt5OX+9449QJNob7DZK1T1r1a2Q==", "requires": { "@types/jsonwebtoken": "^8.5.0", "@types/mongoose-autopopulate": "^0.10.1", @@ -11169,19 +11189,19 @@ } }, "@types/mongoose-autopopulate": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@types/mongoose-autopopulate/-/mongoose-autopopulate-0.10.1.tgz", - "integrity": "sha512-L67MAIE3WEoTtt7a7/spRYk+76lgp67FAP6I38Y9NcC1kQuzwqnukTaJzodfb8180wxHZM4qt68u6x6ptuDRaQ==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@types/mongoose-autopopulate/-/mongoose-autopopulate-0.10.2.tgz", + "integrity": "sha512-YSxSEhszXK9E+7VRLdpYjkXqcRXOPFtG0xZea9n7A+oaHhZ1lSVBm/WvK2Rr746NPrTm/k1tR6uezyG6kyinyg==", "requires": { - "@types/mongoose": "*" + "@types/mongoose": "5.10.5" } }, "@types/mongoose-lean-virtuals": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@types/mongoose-lean-virtuals/-/mongoose-lean-virtuals-0.5.1.tgz", - "integrity": "sha512-bNk+QLjP5VZU4EsJag4xQsjLAa8CEm/SKZDyiC2kM208wIrGum6daD7j45Oqs50bWNGfqZYRuEhh8xZ17D7aEw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@types/mongoose-lean-virtuals/-/mongoose-lean-virtuals-0.5.2.tgz", + "integrity": "sha512-TpAX2RkFXLtNjyciiYxdvYpVuCAv/g1alFTl4ErJWvSOA+DuNDNvfXSH3c8/DXC1ZBzO47TCwHaxI/PET4sqxQ==", "requires": { - "@types/mongoose": "*" + "@types/mongoose": "5.10.5" } }, "@types/multer": { diff --git a/package.json b/package.json index 71cfee4f..4bea58e5 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "homepage": "https://github.com/fosscord/fosscord-api#readme", "dependencies": { - "@fosscord/server-util": "^1.3.23", + "@fosscord/server-util": "^1.3.24", "@types/jest": "^26.0.22", "@types/json-schema": "^7.0.7", "ajv": "^8.4.0", diff --git a/src/Server.ts b/src/Server.ts index 326fcc5c..94aab0f5 100644 --- a/src/Server.ts +++ b/src/Server.ts @@ -93,10 +93,11 @@ export class FosscordServer extends Server { const prefix = Router(); // @ts-ignore this.app = prefix; - prefix.use(RateLimit({ bucket: "global", count: 10, error: 10, window: 5, bot: 250 })); - prefix.use("/guilds/:id", RateLimit({ count: 10, window: 5 })); - prefix.use("/webhooks/:id", RateLimit({ count: 10, window: 5 })); - prefix.use("/channels/:id", RateLimit({ count: 10, window: 5 })); + prefix.use(RateLimit({ bucket: "global", count: 10, window: 5, bot: 250 })); + prefix.use(RateLimit({ bucket: "error", count: 5, error: true, window: 5, bot: 15, onylIp: true })); + prefix.use("/guilds/:id", RateLimit({ count: 5, window: 5 })); + prefix.use("/webhooks/:id", RateLimit({ count: 5, window: 5 })); + prefix.use("/channels/:id", RateLimit({ count: 5, window: 5 })); this.routes = await this.registerRoutes(path.join(__dirname, "routes", "/")); app.use("/api", prefix); // allow unversioned requests diff --git a/src/middlewares/RateLimit.ts b/src/middlewares/RateLimit.ts index 93d69236..89e002df 100644 --- a/src/middlewares/RateLimit.ts +++ b/src/middlewares/RateLimit.ts @@ -36,17 +36,21 @@ export default function RateLimit(opts: { window: number; count: number; bot?: number; - error?: number; webhook?: number; oauth?: number; GET?: number; MODIFY?: number; + error?: boolean; + success?: boolean; + onylIp?: boolean; }) { Cache.init(); // will only initalize it once return async (req: Request, res: Response, next: NextFunction) => { - const bucket_id = opts.bucket || req.path.replace(API_PREFIX_TRAILING_SLASH, ""); - const user_id = req.user_id || getIpAdress(req); + const bucket_id = opts.bucket || req.originalUrl.replace(API_PREFIX_TRAILING_SLASH, ""); + var user_id = getIpAdress(req); + if (!opts.onylIp && req.user_id) user_id = req.user_id; + var max_hits = opts.count; if (opts.bot && req.user_bot) max_hits = opts.bot; if (opts.GET && ["GET", "OPTIONS", "HEAD"].includes(req.method)) max_hits = opts.GET; @@ -59,9 +63,9 @@ export default function RateLimit(opts: { const resetAfterMs = reset - Date.now(); const resetAfterSec = resetAfterMs / 1000; const global = bucket_id === "global"; - console.log("blocked", { resetAfterMs }); if (resetAfterMs > 0) { + console.log("blocked", { resetAfterMs }); return ( res .status(429) @@ -76,26 +80,28 @@ export default function RateLimit(opts: { .send({ message: "You are being rate limited.", retry_after: resetAfterSec, global }) ); } else { + offender.hits = 0; + offender.expires_at = new Date(Date.now() + opts.window * 1000); + offender.blocked = false; // mongodb ttl didn't update yet -> manually update/delete - db.collection("ratelimits").updateOne( - { id: bucket_id, user_id }, - { $set: { hits: 0, expires_at: new Date(Date.now() + opts.window * 1000), blocked: false } } - ); + db.collection("ratelimits").updateOne({ id: bucket_id, user_id }, { $set: offender }); } } next(); + const hitRouteOpts = { bucket_id, user_id, max_hits, window: opts.window }; - if (opts.error) { + if (opts.error || opts.success) { res.once("finish", () => { // check if error and increment error rate limit - if (res.statusCode >= 400) { - // TODO: use config rate limit values - return hitRoute({ bucket_id: "error", user_id, max_hits: opts.error as number, window: opts.window }); + if (res.statusCode >= 400 && opts.error) { + return hitRoute(hitRouteOpts); + } else if (res.statusCode >= 200 && res.statusCode < 300 && opts.success) { + return hitRoute(hitRouteOpts); } }); + } else { + return hitRoute(hitRouteOpts); } - - return hitRoute({ user_id, bucket_id, max_hits, window: opts.window }); }; } @@ -121,7 +127,7 @@ function hitRoute(opts: { user_id: string; bucket_id: string; max_hits: number; { $set: { hits: { $sum: [{ $ifNull: ["$hits", 0] }, 1] }, - blocked: { $gt: ["$hits", opts.max_hits] } + blocked: { $gte: ["$hits", opts.max_hits] } } } ], diff --git a/src/routes/auth/login.ts b/src/routes/auth/login.ts index 2c4084ea..547d115b 100644 --- a/src/routes/auth/login.ts +++ b/src/routes/auth/login.ts @@ -4,12 +4,14 @@ import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { Config, UserModel } from "@fosscord/server-util"; import { adjustEmail } from "./register"; +import RateLimit from "../../middlewares/RateLimit"; const router: Router = Router(); export default router; router.post( "/", + RateLimit({ count: 5, window: 60, onylIp: true }), check({ login: new Length(String, 2, 100), // email or telephone password: new Length(String, 8, 64), diff --git a/src/routes/auth/register.ts b/src/routes/auth/register.ts index f39206f2..83f8dc8c 100644 --- a/src/routes/auth/register.ts +++ b/src/routes/auth/register.ts @@ -6,11 +6,13 @@ import "missing-native-js-functions"; import { generateToken } from "./login"; import { getIpAdress, IPAnalysis, isProxy } from "../../util/ipAddress"; import { HTTPError } from "lambert-server"; +import RateLimit from "../../middlewares/RateLimit"; const router: Router = Router(); router.post( "/", + RateLimit({ count: 2, window: 60 * 60 * 12, onylIp: true, success: true }), check({ username: new Length(String, 2, 32), // TODO: check min password length in config