summary refs log tree commit diff
diff options
context:
space:
mode:
authorFlam3rboy <34555296+Flam3rboy@users.noreply.github.com>2021-07-01 11:16:13 +0200
committerFlam3rboy <34555296+Flam3rboy@users.noreply.github.com>2021-07-01 11:16:13 +0200
commit79e1adc5ce5414eaed8b755603b2317fde22680e (patch)
tree542a1f62dc30c96dd6e178c472e0382e67fd3be0
parent:construction: rate limit (diff)
downloadserver-79e1adc5ce5414eaed8b755603b2317fde22680e.tar.xz
:sparkles: finished Rate Limit
-rw-r--r--package-lock.json50
-rw-r--r--package.json5
-rw-r--r--src/Server.ts4
-rw-r--r--src/middlewares/RateLimit.ts108
4 files changed, 122 insertions, 45 deletions
diff --git a/package-lock.json b/package-lock.json

index 0580a5ea..728f252d 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.21", + "@fosscord/server-util": "^1.3.23", "@types/jest": "^26.0.22", "@types/json-schema": "^7.0.7", "ajv": "^8.4.0", @@ -36,7 +36,8 @@ "mongoose-autopopulate": "^0.12.3", "mongoose-long": "^0.3.2", "multer": "^1.4.2", - "node-fetch": "^2.6.1" + "node-fetch": "^2.6.1", + "require_optional": "^1.0.1" }, "devDependencies": { "@types/bcrypt": "^3.0.0", @@ -549,9 +550,9 @@ } }, "node_modules/@fosscord/server-util": { - "version": "1.3.21", - "resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.21.tgz", - "integrity": "sha512-Mb4FqgkMeI2V+et5mpQkJqeijWNTjv79LZ6rRvgFdz+DwOw75S3A2vLMRqPX9lUB6uT9ZOMd5StQjR6YZNH9Sw==", + "version": "1.3.23", + "resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.23.tgz", + "integrity": "sha512-YxkuMwsJmMpCN4zGCq0LHvUuV9zlR8yTriquPqWfp5Sbj1DdFz7Qqo6wz6cRYb3WRIINouHhV60cbljmUqLIJQ==", "dependencies": { "@types/jsonwebtoken": "^8.5.0", "@types/mongoose-autopopulate": "^0.10.1", @@ -7846,6 +7847,23 @@ "node": ">=0.10.0" } }, + "node_modules/require_optional": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", + "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", + "dependencies": { + "resolve-from": "^2.0.0", + "semver": "^5.1.0" + } + }, + "node_modules/require_optional/node_modules/resolve-from": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", + "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10635,9 +10653,9 @@ } }, "@fosscord/server-util": { - "version": "1.3.21", - "resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.21.tgz", - "integrity": "sha512-Mb4FqgkMeI2V+et5mpQkJqeijWNTjv79LZ6rRvgFdz+DwOw75S3A2vLMRqPX9lUB6uT9ZOMd5StQjR6YZNH9Sw==", + "version": "1.3.23", + "resolved": "https://registry.npmjs.org/@fosscord/server-util/-/server-util-1.3.23.tgz", + "integrity": "sha512-YxkuMwsJmMpCN4zGCq0LHvUuV9zlR8yTriquPqWfp5Sbj1DdFz7Qqo6wz6cRYb3WRIINouHhV60cbljmUqLIJQ==", "requires": { "@types/jsonwebtoken": "^8.5.0", "@types/mongoose-autopopulate": "^0.10.1", @@ -16816,6 +16834,22 @@ "is-finite": "^1.0.0" } }, + "require_optional": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz", + "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==", + "requires": { + "resolve-from": "^2.0.0", + "semver": "^5.1.0" + }, + "dependencies": { + "resolve-from": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz", + "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c=" + } + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json
index 2d1882b4..71cfee4f 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.21", + "@fosscord/server-util": "^1.3.23", "@types/jest": "^26.0.22", "@types/json-schema": "^7.0.7", "ajv": "^8.4.0", @@ -56,7 +56,8 @@ "mongoose-autopopulate": "^0.12.3", "mongoose-long": "^0.3.2", "multer": "^1.4.2", - "node-fetch": "^2.6.1" + "node-fetch": "^2.6.1", + "require_optional": "^1.0.1" }, "devDependencies": { "@types/bcrypt": "^3.0.0", diff --git a/src/Server.ts b/src/Server.ts
index 54c8db0d..326fcc5c 100644 --- a/src/Server.ts +++ b/src/Server.ts
@@ -56,7 +56,7 @@ export class FosscordServer extends Server { db.collection("emojis").createIndex({ id: 1 }, { unique: true }), db.collection("invites").createIndex({ code: 1 }, { unique: true }), db.collection("invites").createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 }), // after 0 seconds of expires_at the invite will get delete - db.collection("ratelimits").createIndex({ created_at: 1 }, { expireAfterSeconds: 1000 }) + db.collection("ratelimits").createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 }) ]); } @@ -69,7 +69,6 @@ export class FosscordServer extends Server { this.app.use(CORS); this.app.use(Authentication); - this.app.use(RateLimit({ count: 10, error: 10, window: 5 })); this.app.use(BodyParser({ inflate: true, limit: 1024 * 1024 * 2 })); const languages = await fs.readdir(path.join(__dirname, "..", "locales")); const namespaces = await fs.readdir(path.join(__dirname, "..", "locales", "en")); @@ -94,6 +93,7 @@ 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 })); diff --git a/src/middlewares/RateLimit.ts b/src/middlewares/RateLimit.ts
index ab69113e..93d69236 100644 --- a/src/middlewares/RateLimit.ts +++ b/src/middlewares/RateLimit.ts
@@ -1,8 +1,16 @@ import { db, MongooseCache, Bucket } from "@fosscord/server-util"; import { NextFunction, Request, Response } from "express"; -import { API_PREFIX, API_PREFIX_TRAILING_SLASH } from "./Authentication"; +import { getIpAdress } from "../util/ipAddress"; +import { API_PREFIX_TRAILING_SLASH } from "./Authentication"; -const Cache = new MongooseCache(db.collection("ratelimits"), [{ $match: { blocked: true } }], { onlyEvents: false, array: true }); +const Cache = new MongooseCache( + db.collection("ratelimits"), + [ + // TODO: uncomment $match and fix error: not receiving change events + // { $match: { blocked: true } } + ], + { onlyEvents: false, array: true } +); // Docs: https://discord.com/developers/docs/topics/rate-limits @@ -37,52 +45,86 @@ export default function RateLimit(opts: { Cache.init(); // will only initalize it once return async (req: Request, res: Response, next: NextFunction) => { - const bucket_id = req.path.replace(API_PREFIX_TRAILING_SLASH, ""); - const user_id = req.user_id; - const max_hits = req.user_bot ? opts.bot : opts.count; - const offender = Cache.data.find((x: Bucket) => x.user && x.id === bucket_id) as Bucket | null; + const bucket_id = opts.bucket || req.path.replace(API_PREFIX_TRAILING_SLASH, ""); + const user_id = req.user_id || getIpAdress(req); + 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; + else if (opts.MODIFY && ["POST", "DELETE", "PATCH", "PUT"].includes(req.method)) max_hits = opts.MODIFY; + + const offender = Cache.data?.find((x: Bucket) => x.user_id == user_id && x.id === bucket_id) as Bucket | null; if (offender && offender.blocked) { - const reset = offender.created_at.getTime() + opts.window; + const reset = offender.expires_at.getTime(); const resetAfterMs = reset - Date.now(); const resetAfterSec = resetAfterMs / 1000; const global = bucket_id === "global"; + console.log("blocked", { resetAfterMs }); - return ( - res - .status(429) - .set("X-RateLimit-Limit", `${max_hits}`) - .set("X-RateLimit-Remaining", "0") - .set("X-RateLimit-Reset", `${reset}`) - .set("X-RateLimit-Reset-After", `${resetAfterSec}`) - .set("X-RateLimit-Global", `${global}`) - .set("Retry-After", `${Math.ceil(resetAfterSec)}`) - .set("X-RateLimit-Bucket", `${bucket_id}`) - // TODO: error rate limit message translation - .send({ message: "You are being rate limited.", retry_after: resetAfterSec, global }) - ); + if (resetAfterMs > 0) { + return ( + res + .status(429) + .set("X-RateLimit-Limit", `${max_hits}`) + .set("X-RateLimit-Remaining", "0") + .set("X-RateLimit-Reset", `${reset}`) + .set("X-RateLimit-Reset-After", `${resetAfterSec}`) + .set("X-RateLimit-Global", `${global}`) + .set("Retry-After", `${Math.ceil(resetAfterSec)}`) + .set("X-RateLimit-Bucket", `${bucket_id}`) + // TODO: error rate limit message translation + .send({ message: "You are being rate limited.", retry_after: resetAfterSec, global }) + ); + } else { + // 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 } } + ); + } } next(); - console.log(req.route); if (opts.error) { 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 }); + } }); } - db.collection("ratelimits").updateOne( - { bucket: bucket_id }, + return hitRoute({ user_id, bucket_id, max_hits, window: opts.window }); + }; +} + +function hitRoute(opts: { user_id: string; bucket_id: string; max_hits: number; window: number }) { + return db.collection("ratelimits").updateOne( + { id: opts.bucket_id, user_id: opts.user_id }, + [ { - $set: { - id: bucket_id, - user_id, - created_at: new Date(), - $cond: { if: { $gt: ["$hits", max_hits] }, then: true, else: false } - }, - $inc: { hits: 1 } + $replaceRoot: { + newRoot: { + // similar to $setOnInsert + $mergeObjects: [ + { + id: opts.bucket_id, + user_id: opts.user_id, + expires_at: new Date(Date.now() + opts.window * 1000) + }, + "$$ROOT" + ] + } + } }, - { upsert: true } - ); - }; + { + $set: { + hits: { $sum: [{ $ifNull: ["$hits", 0] }, 1] }, + blocked: { $gt: ["$hits", opts.max_hits] } + } + } + ], + { upsert: true } + ); }